Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
508952a1ea | ||
|
|
15ef106eb5 | ||
|
|
a42edcf6d6 | ||
|
|
0267233f87 | ||
|
|
6b45092bc8 | ||
|
|
6d39188967 | ||
|
|
c5f682c5fe | ||
|
|
073fb9e9af | ||
|
|
2f45c78741 | ||
|
|
6d41680297 | ||
|
|
b2a50376df | ||
|
|
3c4be89ead | ||
|
|
89d2c259f6 |
364
plans/feed-upgrade.md
Normal file
364
plans/feed-upgrade.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Feed Upgrade — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
A series of improvements to the feed page and post interaction bar, centered on:
|
||||
1. **Simplified feed** — show posts without inline replies, just counts
|
||||
2. **Post thread page** — click comment button to open post with full comment thread in a new tab
|
||||
3. **Zap capability indicators** — show what the recipient can receive (nutzap, Lightning)
|
||||
4. **Zap rail selector** — let users choose between nutzap and Lightning melt
|
||||
5. **Balance check and error handling** — pre-flight checks and better error messages
|
||||
|
||||
The project uses **Cashu as the only payment rail** — nutzaps (NIP-61) for ecash transfers, and Cashu melt for Lightning zaps (NIP-57). No NWC, CLINK, or onchain needed.
|
||||
|
||||
### Current state
|
||||
|
||||
- [`www/feed.html`](www/feed.html) — shows posts with inline replies (toggled by "Comments" button). Replies appear under the post as they occur. This is unreliable.
|
||||
- [`www/feed2.html`](www/feed2.html) — copy of feed.html, to be modified per this plan
|
||||
- [`www/post.html`](www/post.html) — posting/composer page (NOT a thread viewer). Accepts `?event=`, `?npub=`, `?profile=` params.
|
||||
- [`www/note.html`](www/note.html) — long-form note editor (kind 30023/30024), NOT for kind 1 threads
|
||||
- [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) — renders the interaction bar (like, comment, quote, zap buttons) and handles zap routing
|
||||
- [`www/js/zaps.mjs`](www/js/zaps.mjs) — NIP-57 Lightning zap protocol
|
||||
|
||||
### URL schema (existing convention)
|
||||
|
||||
The project uses `URLSearchParams` with query parameters:
|
||||
- `?npub=<npub1...>` — Nostr public key (bech32)
|
||||
- `?pubkey=<hex>` — Nostr public key (hex)
|
||||
- `?event=<hex|nevent|note>` — Event identifier
|
||||
- `?auth=required|optional|none` — Auth mode
|
||||
|
||||
The new `post-feed.html` page will follow this schema: `post-feed.html?event=<hex|nevent|note>`
|
||||
|
||||
### CSS theme
|
||||
|
||||
The project uses CSS variables (never hardcode colors):
|
||||
- `--primary-color` (black in light mode, white in dark mode) — main text/icons
|
||||
- `--secondary-color` (white in light mode, black in dark mode) — background
|
||||
- `--accent-color` (#ff0000 red, both modes) — the **only** highlight color
|
||||
- `--muted-color` (#dddddd light / #777777 dark) — dimmed/disabled
|
||||
|
||||
All indicators must use these variables. "Colored" = `var(--accent-color)`, "dimmed" = `var(--muted-color)`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Simplified Feed (feed2.html)
|
||||
|
||||
Remove inline replies from the feed. Show only the original post with interaction counts (likes, comments, zaps, nutzaps). Clicking the comment button opens the post thread page in a **new tab**.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
|
||||
|
||||
- [x] **1.2** Change the comment button behavior — instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=<eventId>` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`.
|
||||
|
||||
- [x] **1.3** Keep the interaction bar functional in the feed:
|
||||
- ✅ Like button — works inline (no navigation)
|
||||
- ✅ Zap button — works inline (opens zap dialog, sends nutzap or Lightning melt)
|
||||
- ✅ Nutzap count display — shows total nutzaps received
|
||||
- ✅ Comment button — opens `post-feed.html?event=<eventId>` in a new tab
|
||||
- ✅ Quote button — opens quote composer (no navigation)
|
||||
|
||||
- [x] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) — the feed should still update these counts as events arrive, just not render the reply content inline.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/feed2.html`](www/feed2.html) | Remove inline comment rendering, comment button opens post-feed.html in new tab |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Post Thread Page (post-feed.html)
|
||||
|
||||
Create a new `www/post-feed.html` page that shows a post with its full comment thread — all replies, with the ability to reply inline.
|
||||
|
||||
### URL schema
|
||||
|
||||
```
|
||||
post-feed.html?event=<hex|nevent|note>
|
||||
```
|
||||
|
||||
Accepts the same event identifier formats as the rest of the project:
|
||||
- Hex event ID: `post-feed.html?event=abc123...`
|
||||
- nevent: `post-feed.html?event=nevent1q...`
|
||||
- note: `post-feed.html?event=note1...`
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
|
||||
|
||||
- [x] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
|
||||
|
||||
- [x] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
|
||||
|
||||
- [x] **2.4** Fetch all replies — kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`. **Updated:** Now uses recursive fetching (`fetchRepliesRecursive`) to find nested replies up to 5 levels deep.
|
||||
|
||||
- [x] **2.5** Render the comment thread below the original post:
|
||||
- Threaded with vertical indent lines (Phase 2.5)
|
||||
- Each reply rendered using `renderPostItem` from `post-interactions2.mjs`
|
||||
- Depth-first ordering with level computation from NIP-10 e tags
|
||||
|
||||
- [x] **2.6** Add an inline composer at the **top** (under the main post, before replies) for posting replies. The composer:
|
||||
- Tags the original post: `['e', postId, '', 'reply']`
|
||||
- Tags the original author: `['p', postPubkey]`
|
||||
- Uses the existing post composer component (`post-composer.mjs`)
|
||||
- Styled to match `post.html`'s composer (`.topPostComposerInput` CSS)
|
||||
|
||||
- [x] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive. New replies trigger a 1-level recursive fetch for their sub-replies.
|
||||
|
||||
- [x] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions2.mjs`. The comment button on a reply opens `post-feed.html?event=<replyId>` in a new tab for that reply's sub-thread.
|
||||
|
||||
### Files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `www/post-feed.html` | New post thread page — shows post + all replies + inline composer |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.5: Threaded Replies with Vertical Lines (post-feed.html)
|
||||
|
||||
Upgrade the flat reply list in `post-feed.html` to show **threaded replies with vertical indent lines**, matching Amethyst's thread view.
|
||||
|
||||
### How Amethyst does it
|
||||
|
||||
Amethyst uses two key components:
|
||||
|
||||
1. **`ThreadLevelCalculator.replyLevel(note, cachedLevels)`** — computes the depth of each reply recursively:
|
||||
- Root post (no `e` tag reply) → level 0
|
||||
- A reply's level = `max(parent's level for each parent) + 1`
|
||||
- Parents are determined by NIP-10 `e` tags in the event
|
||||
|
||||
2. **`Modifier.drawReplyLevel(level, color, selected)`** — draws vertical lines to the left of each post:
|
||||
- Draws `level` vertical lines, each 2px wide, spaced 3px apart
|
||||
- The last line (closest to the post) uses the "selected" color
|
||||
- All other lines use the "muted" color
|
||||
- The post content is padded left by `2 + (level * 3)px`
|
||||
|
||||
### Visual design
|
||||
|
||||
```
|
||||
Original post (level 0)
|
||||
│ Reply A (level 1) — direct reply to original
|
||||
│ │ Reply A1 (level 2) — reply to Reply A
|
||||
│ │ Reply A2 (level 2) — another reply to Reply A
|
||||
│ Reply B (level 1) — another direct reply to original
|
||||
│ │ Reply B1 (level 2) — reply to Reply B
|
||||
│ │ │ Reply B1a (level 3) — reply to Reply B1
|
||||
│ Reply C (level 1) — yet another direct reply
|
||||
```
|
||||
|
||||
Each `│` is a vertical line drawn with CSS `border-left` on a container div. The lines use:
|
||||
- `var(--muted-color)` for non-selected levels
|
||||
- `var(--accent-color)` for the current post's level (the post being viewed)
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html`:
|
||||
- Build a map of `eventId → parentEventId` from the `e` tags (NIP-10 marked reply tags)
|
||||
- For each event, compute its level by walking up the parent chain to the root
|
||||
- Root post = level 0, direct reply = level 1, reply to reply = level 2, etc.
|
||||
- Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1)
|
||||
- Return a `Map<eventId, level>`
|
||||
|
||||
- [x] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
|
||||
- Root post first
|
||||
- Then replies grouped by parent, in chronological order within each group
|
||||
- This makes the vertical lines visually contiguous — a reply's children appear right below it
|
||||
|
||||
- [x] **2.5.3** Render each reply with vertical indent lines:
|
||||
- Wrap each reply in a container div with `padding-left: calc(level * 12px)`
|
||||
- For each level 0..N-1, add a vertical line using positioned divs with `background: var(--muted-color)`
|
||||
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
|
||||
|
||||
- [x] ~~**2.5.4** Add collapse/expand functionality~~ — **Removed per user request.** All replies are shown, no collapse.
|
||||
|
||||
- [x] **2.5.5** Highlight the focused post:
|
||||
- If the URL has a `focus=<eventId>` param, scroll to and highlight that post
|
||||
- The highlighted post's vertical line uses `var(--accent-color)` and card gets `var(--accent-color)` outline
|
||||
|
||||
### CSS approach (CSS variables only)
|
||||
|
||||
```css
|
||||
.reply-thread-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-color);
|
||||
}
|
||||
|
||||
.reply-thread-line.selected {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
.reply-item {
|
||||
position: relative;
|
||||
padding-left: 12px; /* per level */
|
||||
}
|
||||
```
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/post-feed.html`](www/post-feed.html) | Add level computation, depth-first sorting, vertical line rendering, collapse/expand |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Zap Capability Indicators in the Feed
|
||||
|
||||
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
|
||||
|
||||
### Design
|
||||
|
||||
Compact indicators next to the existing zap button, using the project's CSS variables:
|
||||
|
||||
| Indicator | CSS | Meaning |
|
||||
|-----------|-----|---------|
|
||||
| 🥜 (accent color) | `color: var(--accent-color)` | Recipient can receive nutzaps — has kind 10019 with a shared mint |
|
||||
| 🥜 (muted) | `color: var(--muted-color)` | Recipient has kind 10019 but no shared mint with your wallet |
|
||||
| ⚡ (accent color) | `color: var(--accent-color)` | Recipient can receive Lightning zaps — has lud16 |
|
||||
| ⚡ (muted) | `color: var(--muted-color)` | Recipient has no lud16 — cannot receive Lightning zaps |
|
||||
|
||||
Tooltip on hover shows details: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability".
|
||||
|
||||
### When and where to fetch the data
|
||||
|
||||
Two-tier strategy based on whether the author is a followed user or not:
|
||||
|
||||
**Tier 1: Followed users (proactive fetch on startup)**
|
||||
- The worker already fetches the user's kind 3 (contact list) on startup (line 1799 of `ndk-worker.js`)
|
||||
- After the kind 3 is loaded, proactively fetch kind 10019 for all followed pubkeys in a batch
|
||||
- This happens once on startup, results cached in the worker's Dexie/IndexedDB
|
||||
- Followed users are the most likely zap recipients, and their 10019 rarely changes
|
||||
- Add a subscription for kind 10019 updates for followed pubkeys (so changes are picked up)
|
||||
|
||||
**Tier 2: Non-followed users (lazy fetch on first encounter)**
|
||||
- When a post by a non-followed user appears in the feed, lazily fetch their kind 10019
|
||||
- Use the existing `walletFetchMintList` worker function
|
||||
- Cache the result per pubkey in an in-memory Map with TTL (5 minutes)
|
||||
- Only fetch once per pubkey per session (unless cache expires)
|
||||
|
||||
**What's already available (no fetch needed):**
|
||||
- lud16 (Lightning address) — already cached in `profile-cache.mjs` when post headers are rendered. The profile fetch already happens for every post in the feed.
|
||||
- The sender's own wallet mints — already in the worker's `directProofStore`, accessible via `walletGetMints`
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **3.1** Create a `resolveZapCapabilities(pubkey)` function in [`www/js/zaps.mjs`](www/js/zaps.mjs) that checks:
|
||||
- Does the recipient have a lud16 (Lightning address)? → check `profile-cache.mjs` cache (already fetched for post rendering) → `canLightning: true`
|
||||
- Does the recipient have a kind 10019 (nutzap mint list)? → check local cache first, then fetch via `walletFetchMintList` if not cached
|
||||
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints (from `walletGetMints`)? → `canNutzap: true`, `sharedMints: [...]`
|
||||
- Return `{ canLightning, canNutzap, sharedMints, nutzapMints, lud16 }`
|
||||
|
||||
- [ ] **3.2** Add a proactive kind 10019 batch fetch for followed users in the worker:
|
||||
- After the kind 3 (contact list) is loaded on startup, collect all followed pubkeys
|
||||
- Fetch kind 10019 for all followed pubkeys: `ndk.fetchEvents({ kinds: [10019], authors: [...followedPubkeys] })`
|
||||
- Cache the results in the worker's IndexedDB so they persist across sessions
|
||||
- Add a subscription for kind 10019 updates for followed pubkeys
|
||||
|
||||
- [ ] **3.3** For non-followed users, lazily fetch kind 10019 via `walletFetchMintList`:
|
||||
- Cache results in an in-memory Map with TTL (5 minutes) in `zaps.mjs`
|
||||
- Only fetch if not already in the worker's IndexedDB cache or the in-memory cache
|
||||
- Non-blocking: render the interaction bar without badges, then update when the fetch completes
|
||||
|
||||
- [ ] **3.4** Update the zap button rendering in `post-interactions.mjs` to show capability indicators:
|
||||
- Add a small badge element next to the zap button
|
||||
- Show 🥜 in `var(--accent-color)` if `canNutzap` is true
|
||||
- Show 🥜 in `var(--muted-color)` if recipient has 10019 but no shared mint
|
||||
- Show ⚡ in `var(--accent-color)` if `canLightning` is true
|
||||
- Add tooltip with shared mint details
|
||||
|
||||
- [ ] **3.5** Make the capability check async and non-blocking — render the interaction bar immediately, then update the badges when the capability check completes. The lud16 check is instant (from profile cache); the 10019 check may take a moment for non-followed users.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` with caching, lazy fetch for non-followed users |
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | Add proactive kind 10019 batch fetch for followed users on startup |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Zap Rail Selector in the Zap Dialog
|
||||
|
||||
When the user taps the zap button, show which rails are available and let them choose.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User taps zap button] --> B[Show zap dialog with amount input]
|
||||
B --> C{What rails are available?}
|
||||
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
|
||||
C -->|Lightning only| E[Show Lightning only]
|
||||
C -->|Nutzap only| F[Show Nutzap only]
|
||||
C -->|Neither| G[Show error: recipient cannot receive zaps]
|
||||
D --> H[User selects rail + amount]
|
||||
H --> I{Rail selected}
|
||||
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
|
||||
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
|
||||
E --> K
|
||||
F --> J
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **4.1** Update `promptZapDetails` in [`www/js/zaps.mjs`](www/js/zaps.mjs) to accept and display rail options:
|
||||
- If nutzap is available → show "🥜 Nutzap" option
|
||||
- If Lightning is available → show "⚡ Lightning" option
|
||||
- Let the user pick which rail to use via a toggle/selector
|
||||
- Selected rail uses `var(--accent-color)`, unselected uses `var(--muted-color)`
|
||||
|
||||
- [ ] **4.2** Show balance info in the dialog: "Cashu balance: 261 sats". If amount > balance → show warning in `var(--accent-color)`.
|
||||
|
||||
- [ ] **4.3** Default rail selection:
|
||||
- If nutzap is available and amount < 1000 sats → default to nutzap (lower fees)
|
||||
- If nutzap is available and amount ≥ 1000 sats → default to Lightning
|
||||
- If nutzap is not available → default to Lightning
|
||||
|
||||
- [ ] **4.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
|
||||
|
||||
- [ ] **4.5** Show shared mint info when nutzap is selected: "Will send via mint.minibits.cash/Bitcoin"
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Update `promptZapDetails` with rail selector UI |
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Update `handleZapClick` to respect user's rail choice |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Balance Check and Error Handling
|
||||
|
||||
- [ ] **5.1** Before sending a zap, check the user's Cashu balance (via `walletGetBalance`):
|
||||
- If balance < zap amount → show "Insufficient balance. Current: X sats, needed: Y sats" in `var(--accent-color)`
|
||||
- If balance is sufficient but barely → show confirmation "This will use most of your balance. Continue?"
|
||||
|
||||
- [ ] **5.2** Improve error messages for melt failures:
|
||||
- "Mint couldn't route Lightning payment" (mint liquidity issue)
|
||||
- "Mint returned an error: [details]" (mint API error)
|
||||
- "Proofs were spent but payment failed — try again or contact the mint"
|
||||
|
||||
- [ ] **5.3** After a successful zap, refresh the balance display.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add balance check before zap, improve error handling |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** (simplified feed) — remove inline replies from feed2.html, comment button opens post-feed.html in new tab
|
||||
2. **Phase 2** (post thread page) — create post-feed.html to show full post + comment thread
|
||||
3. **Phase 3** (zap capability indicators) — show what the recipient can receive
|
||||
4. **Phase 4** (rail selector) — let users choose nutzap vs Lightning melt
|
||||
5. **Phase 5** (balance + errors) — pre-flight checks and better error messages
|
||||
|
||||
Phases 1-2 are the feed restructuring. Phases 3-5 are zap improvements.
|
||||
@@ -1,406 +1,178 @@
|
||||
# Unified Wallet Page (`wallet.html`) — Implementation Plan
|
||||
# Cashu Wallet Enhancements — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Create a new unified wallet page (`www/wallet.html`) that mirrors Amethyst's multi-rail wallet architecture, bringing together four payment rails into a single page:
|
||||
Enhance the existing `www/cashu.html` page and zap flow, centered on **Cashu as the primary (and only) payment rail**. The core functionality is already implemented — this plan adds UI improvements to show zap capabilities in the feed and let users choose their zap rail.
|
||||
|
||||
1. **Bitcoin (Onchain / Taproot)** — NIP-BC onchain zaps, Taproot address display, onchain transaction history
|
||||
2. **Cashu (NIP-60 ecash)** — Fix the existing kind 17375 content format bug, then migrate cashu.html functionality into the new page
|
||||
3. **NWC (Nostr Wallet Connect / NIP-47)** — Lightning wallet connections via `nostr+walletconnect://` URIs
|
||||
4. **CLINK Debit** — Spend-only debit pointers (`ndebit1...`) for CLINK protocol
|
||||
### Design philosophy: Cashu-only
|
||||
|
||||
This plan also fixes the critical NIP-60 compliance bug discovered during investigation: the current `ndk-worker.js` writes a proprietary JSON object as the kind 17375 encrypted content instead of the NIP-60 standard tag-array format, which makes Amethyst (and any standard NIP-60 client) unable to read the wallet's mints or privkey.
|
||||
The project uses **Cashu for everything** — no NWC, CLINK, onchain, or Lightning channels to maintain:
|
||||
|
||||
| Payment type | How it works | Already implemented? |
|
||||
|-------------|-------------|---------------------|
|
||||
| **Nutzap (NIP-61)** | Send ecash proofs locked to recipient's p2pk via kind 9321 | ✅ Yes — `walletSendNutzap` |
|
||||
| **Lightning zap (NIP-57)** | Melt Cashu ecash at a mint to pay a BOLT11 invoice | ✅ Yes — `walletPayInvoice` |
|
||||
| **P2P ecash transfer** | Send cashuB token directly to another user | ✅ Yes — `walletSendToken` |
|
||||
| **Lightning deposit** | Pay a BOLT11 invoice, mint issues ecash proofs | ✅ Yes — `walletCreateDeposit` |
|
||||
| **Lightning withdrawal** | Melt ecash to pay an external BOLT11 invoice | ✅ Yes — `walletPayInvoice` |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## Phase 1: NIP-60 Compliance Fix (DONE ✅)
|
||||
|
||||
Already completed and deployed (v0.7.19–v0.7.24):
|
||||
- Fixed kind 17375 content format from proprietary `{mints, nutzap}` object to NIP-60 tag-array `[["mint",url],["privkey",hex]]`
|
||||
- Removed non-standard `pubkey` tag from 17375 public tags
|
||||
- Added `parseWalletContent()` backwards-compat shim for reading both formats
|
||||
- Added auto-migration for legacy 17375 events on startup
|
||||
- Added "Republish Wallet (kind 17375)" button with lightweight 17375-only republish
|
||||
- Added `ensureDirectNutzapP2pk()` call in `publishDirectProofs` so privkey is always included
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Zap Capability Indicators in the Feed
|
||||
|
||||
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
|
||||
|
||||
### Current state
|
||||
|
||||
The interaction bar in [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) already renders:
|
||||
- ⚡ Zap button with count
|
||||
- 🥜 Nutzap count display (separate element showing total nutzaps received)
|
||||
|
||||
### What to add
|
||||
|
||||
Small capability icons/badges next to the zap button showing what the recipient can receive:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ⚡ 5 🥜 2 🥜✅ ⚡✅ 💬 3 🔄 1 2h ago │
|
||||
│ └─ zap ─┘ └─ nutzap ─┘ └─ capability badges ─┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Or a more compact design — color the zap icon differently based on capabilities:
|
||||
|
||||
| Visual | Meaning |
|
||||
|--------|---------|
|
||||
| ⚡ (default color) | Recipient can receive Lightning zaps (has lud16) |
|
||||
| ⚡ + 🥜 (nut badge) | Recipient can also receive nutzaps (has kind 10019 with shared mint) |
|
||||
| ⚡ (dimmed) | Recipient cannot receive any zaps (no lud16, no 10019) |
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] **2.1** Create a `resolveZapCapabilities(pubkey)` function that checks:
|
||||
- Does the recipient have a lud16 (Lightning address)? → can receive Lightning zaps
|
||||
- Does the recipient have a kind 10019 (nutzap mint list)? → can receive nutzaps
|
||||
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints?
|
||||
|
||||
- [ ] **2.2** Cache the results per pubkey (the kind 10019 and lud16 don't change often). Use a simple in-memory Map with TTL.
|
||||
|
||||
- [ ] **2.3** Update `createInteractionItem` for the zap type to show capability indicators:
|
||||
- Add a small 🥜 badge on the zap button if the recipient can receive nutzaps
|
||||
- Add a tooltip showing the shared mints
|
||||
- Dim the zap button if the recipient can't receive any zaps
|
||||
|
||||
- [ ] **2.4** Fetch kind 10019 events for post authors as the feed loads. The worker already fetches kind 10019 for nutzap sending — extend this to cache the results for feed display. Use the existing `walletFetchMintList` worker function.
|
||||
|
||||
- [ ] **2.5** Show shared mint info on hover/tooltip: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability"
|
||||
|
||||
### Where the code lives
|
||||
|
||||
| File | What to change |
|
||||
|------|---------------|
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` function (or extend existing `resolveZapSpecForPubkey`) |
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | May need a lightweight "fetch 10019 for display" message (or reuse `walletFetchMintList`) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Zap Rail Selector in the Zap Dialog
|
||||
|
||||
When the user taps the zap button, show which rails are available and let them choose.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Page["wallet.html (UI)"]
|
||||
A[Header + Sidenav]
|
||||
B[Bitcoin Onchain Section]
|
||||
C[Cashu Wallet Section]
|
||||
D[NWC Wallet List]
|
||||
E[CLINK Debit List]
|
||||
F[Add Wallet Modal]
|
||||
end
|
||||
|
||||
subgraph Modules["JS Modules"]
|
||||
G["wallet-ui.mjs - new orchestrator"]
|
||||
H["cashu-wallet.mjs - existing, updated"]
|
||||
I["nwc-wallet.mjs - new"]
|
||||
J["onchain-wallet.mjs - new"]
|
||||
K["clink-wallet.mjs - new"]
|
||||
end
|
||||
|
||||
subgraph Worker["ndk-worker.js"]
|
||||
L[NDK Core + Relays]
|
||||
M[Direct Cashu proof store]
|
||||
N["NWC request/response - new"]
|
||||
O["Onchain zap events - new"]
|
||||
end
|
||||
|
||||
subgraph NDK["ndk-core.bundle.js"]
|
||||
P[NDKCashuWallet]
|
||||
Q[NDKNWCWallet]
|
||||
R[NDKEvent / NDKKind]
|
||||
S[cashu-ts CashuWallet]
|
||||
end
|
||||
|
||||
A --> G
|
||||
B --> J
|
||||
C --> H
|
||||
D --> I
|
||||
A[User taps zap button] --> B[Show zap dialog with amount input]
|
||||
B --> C{What rails are available?}
|
||||
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
|
||||
C -->|Lightning only| E[Show Lightning only]
|
||||
C -->|Nutzap only| F[Show Nutzap only]
|
||||
C -->|Neither| G[Show error: recipient cannot receive zaps]
|
||||
D --> H[User selects rail + amount]
|
||||
H --> I{Rail selected}
|
||||
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
|
||||
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
|
||||
E --> K
|
||||
F --> G
|
||||
G --> H
|
||||
G --> I
|
||||
G --> J
|
||||
G --> K
|
||||
H --> M
|
||||
I --> N
|
||||
J --> O
|
||||
K --> N
|
||||
M --> L
|
||||
N --> L
|
||||
O --> L
|
||||
H --> S
|
||||
I --> Q
|
||||
H --> P
|
||||
J --> R
|
||||
F --> J
|
||||
```
|
||||
|
||||
### File Structure
|
||||
- [ ] **3.1** Update `promptZapDetails` in `zaps.mjs` to accept and display rail options:
|
||||
- If nutzap is available (recipient has 10019 with shared mint) → show "🥜 Nutzap" option
|
||||
- If Lightning is available (recipient has lud16) → show "⚡ Lightning" option
|
||||
- Let the user pick which rail to use via a toggle/selector
|
||||
|
||||
| File | Type | Purpose |
|
||||
|------|------|---------|
|
||||
| `www/wallet.html` | New | Page HTML + page-specific styles + inline module script |
|
||||
| `www/js/wallet-ui.mjs` | New | Top-level orchestrator that renders all four rail sections |
|
||||
| `www/js/cashu-wallet.mjs` | Existing, updated | Cashu controller — update to use NIP-60 tag-array format |
|
||||
| `www/js/nwc-wallet.mjs` | New | NWC wallet list management, balance fetch, pay invoice |
|
||||
| `www/js/onchain-wallet.mjs` | New | Bitcoin onchain section — Taproot address, onchain zap send/history |
|
||||
| `www/js/clink-wallet.mjs` | New | CLINK debit pointer management, budget requests |
|
||||
- [ ] **3.2** Show balance info in the dialog:
|
||||
- "Cashu balance: 261 sats"
|
||||
- If amount > balance → show warning "Insufficient balance"
|
||||
|
||||
The module approach follows the existing pattern used by `cashu-wallet.mjs` and `post-interactions.mjs`.
|
||||
- [ ] **3.3** Default rail selection:
|
||||
- If nutzap is available and amount < 1000 sats → default to nutzap (lower fees)
|
||||
- If nutzap is available and amount ≥ 1000 sats → default to Lightning (more reliable for larger amounts)
|
||||
- If nutzap is not available → default to Lightning
|
||||
|
||||
- [ ] **3.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Fix NIP-60 Compliance Bug (Critical, do first)
|
||||
## Phase 4: cashu.html UI Polish
|
||||
|
||||
### Problem
|
||||
Minor UI improvements to the existing cashu.html page:
|
||||
|
||||
In [`www/ndk-worker.js`](www/ndk-worker.js:2758) lines 2758–2769, the worker hand-rolls the kind 17375 wallet event with a **proprietary JSON object** as the encrypted content:
|
||||
- [ ] **4.1** Consider renaming the page title from "CASHU" to "WALLET" since it's the project's unified wallet.
|
||||
|
||||
```js
|
||||
// CURRENT (wrong — proprietary)
|
||||
const walletPayloadObj = {
|
||||
mints: getDirectWalletMints(),
|
||||
...(directNutzapPrivateKeyHex
|
||||
? { nutzap: { privkey: directNutzapPrivateKeyHex, p2pk: directNutzapP2pk } }
|
||||
: {})
|
||||
};
|
||||
```
|
||||
- [ ] **4.2** Update the sidenav entry in `index.html` from "CASHU" to "WALLET" (keeping the same `cashu.html` URL).
|
||||
|
||||
NIP-60 requires the decrypted content to be a **JSON array of tag arrays** (the same format NDK's `payloadForEvent` and Amethyst's `CashuWalletEvent.build()` produce):
|
||||
|
||||
```json
|
||||
[["mint","https://mint.example.com"],["privkey","<hex>"]]
|
||||
```
|
||||
|
||||
### Fix
|
||||
|
||||
- [ ] **1.1** Change the wallet payload construction in `ndk-worker.js` to the NIP-60 tag-array format:
|
||||
|
||||
```js
|
||||
// FIXED (NIP-60 standard)
|
||||
const walletPayloadObj = [
|
||||
...getDirectWalletMints().map((url) => ["mint", url]),
|
||||
...(directNutzapPrivateKeyHex ? [["privkey", directNutzapPrivateKeyHex]] : [])
|
||||
];
|
||||
const walletPayload = JSON.stringify(walletPayloadObj);
|
||||
```
|
||||
|
||||
- [ ] **1.2** Remove the non-standard `pubkey` tag from the public tags of the 17375 event (line 2755). The p2pk pubkey belongs on the kind 10019 `NutzapInfoEvent`, not on kind 17375. Amethyst reads the p2pk from the decrypted `privkey` tag, not from a public `pubkey` tag.
|
||||
|
||||
- [ ] **1.3** Update the wallet event **reading** path in `ndk-worker.js` (`hydrateNutzapKeyFromWalletEvents` and any other 17375 decryption) to parse the tag-array format instead of the object format. Add a backwards-compatibility shim: if the decrypted content parses as an object (old format), extract `mints` and `nutzap.privkey` from it; if it parses as an array, extract `mint` and `privkey` tags.
|
||||
|
||||
- [ ] **1.4** Add a one-time migration: when the worker detects an old-format 17375 event on startup, automatically republish it in the new tag-array format and NIP-09 delete the old one. This ensures existing users get upgraded transparently.
|
||||
|
||||
- [ ] **1.5** Test with Amethyst: after republishing, verify that Amethyst shows the wallet's mints and the "Could not read the existing wallet key" error no longer appears.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Create `wallet.html` Page Shell
|
||||
|
||||
- [ ] **2.1** Create `www/wallet.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav with AI section, footer with relay status.
|
||||
|
||||
- [ ] **2.2** Add a sidenav nav entry to [`www/index.html`](www/index.html:543) — replace or augment the existing `cashu` entry with a `wallet` entry pointing to `wallet.html`.
|
||||
|
||||
- [ ] **2.3** Page layout — single-column centered (like `cashu.html` and `post.html`), with four stacked sections:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ HEADER (hamburger + "WALLET") │
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ BITCOIN (Onchain) │ │
|
||||
│ │ ₿ Taproot address │ │
|
||||
│ │ Balance: 0 sats │ │
|
||||
│ │ [Send Onchain Zap] [History]│ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ CASHU (NIP-60 ecash) │ │
|
||||
│ │ Balance: 1,234 sats │ │
|
||||
│ │ [Receive] [Send] [Deposit] │ │
|
||||
│ │ [Withdraw] [Mints] [History]│ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ LIGHTNING (NWC) │ │
|
||||
│ │ ┌─ Alby ──────── 500 sats ┐│ │
|
||||
│ │ └──────────────────────────┘│ │
|
||||
│ │ ┌─ Mutiny ────── 1,200 sats┐│ │
|
||||
│ │ └──────────────────────────┘│ │
|
||||
│ │ [+ Add NWC Connection] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ CLINK DEBIT │ │
|
||||
│ │ ┌─ My Debit ─── spend-only ┐│ │
|
||||
│ │ └──────────────────────────┘│ │
|
||||
│ │ [+ Add CLINK Pointer] │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ [+ ADD WALLET] │ │
|
||||
│ │ Choose: Cashu | NWC | CLINK│ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────┤
|
||||
│ FOOTER (relay status + balance) │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- [ ] **2.4** Sidenav sections: keep the existing AI section, relay section. Move the Cashu mint-discovery and zap-settings sections from `cashu.html` into the `wallet.html` sidenav.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cashu Section (Migrate from cashu.html)
|
||||
|
||||
- [ ] **3.1** Port the Cashu UI from [`www/cashu.html`](www/cashu.html) into the Cashu section of `wallet.html` — balance card, action buttons (Receive/Send/Deposit/Withdraw), action panels, transaction history, mint management.
|
||||
|
||||
- [ ] **3.2** Reuse the existing [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) controller — it already wraps the worker wallet API. No changes needed to the controller itself (the fix is in the worker, Phase 1).
|
||||
|
||||
- [ ] **3.3** Port the mint-discovery sidebar and zap-settings sidebar from `cashu.html` into `wallet.html`'s sidenav.
|
||||
|
||||
- [ ] **3.4** Port the nutzap mint-list (kind 10019) publishing UI from `cashu.html`.
|
||||
|
||||
- [ ] **3.5** After `wallet.html` is complete, redirect `cashu.html` to `wallet.html` (or keep `cashu.html` as a thin redirect) and update the sidenav link in `index.html`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: NWC (Nostr Wallet Connect) Section
|
||||
|
||||
NIP-47 (NWC) allows connecting to external Lightning wallets via `nostr+walletconnect://` URIs. The NDK bundle already includes [`NDKNWCWallet`](www/ndk-core.bundle.js:30422) which handles the NIP-47 protocol.
|
||||
|
||||
### Storage
|
||||
|
||||
NWC connection URIs are stored as a **kind 37550** application event (or in localStorage as a simpler approach initially). Each entry contains:
|
||||
- Wallet name (user-defined label)
|
||||
- NWC URI (`nostr+walletconnect://<pubkey>?relay=<relay>&secret=<secret>`)
|
||||
- Whether it's the default wallet
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **4.1** Create `www/js/nwc-wallet.mjs` with:
|
||||
- `loadNwcWallets()` — read stored NWC connections from localStorage (key: `nwcWallets`)
|
||||
- `addNwcWallet(name, uri)` — parse and validate the URI, store it
|
||||
- `removeNwcWallet(id)` — remove a connection
|
||||
- `setDefaultNwcWallet(id)` — set the default wallet for zaps
|
||||
- `fetchNwcBalance(walletId)` — use `NDKNWCWallet` to call `get_balance` via NIP-47
|
||||
- `payInvoiceViaNwc(walletId, bolt11)` — use `NDKNWCWallet` to call `pay_invoice`
|
||||
- `getNwcInfo(walletId)` — call `get_info` for wallet capabilities
|
||||
|
||||
- [ ] **4.2** Add NWC worker support in `ndk-worker.js`:
|
||||
- New message handlers: `nwcAddWallet`, `nwcRemoveWallet`, `nwcGetBalance`, `nwcPayInvoice`, `nwcGetInfo`
|
||||
- Use `NDKNWCWallet` from the bundle to make NIP-47 requests
|
||||
- The NWC wallet sends kind 23194 events to the wallet service's relay and listens for responses
|
||||
|
||||
- [ ] **4.3** NWC UI in `wallet.html`:
|
||||
- Wallet list — each row shows name, balance, default badge, delete button
|
||||
- "Add NWC Connection" form — name input + URI input + paste/scan
|
||||
- Wallet detail view — balance, transactions, send Lightning payment
|
||||
- URI validation: must start with `nostr+walletconnect://` or `nostrwalletconnect://`
|
||||
|
||||
- [ ] **4.4** Integrate NWC as a zap payment source in [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs:1585) — when sending a zap, if the user has an NWC wallet configured, use it to pay the LN invoice automatically (replacing the current "open external wallet" fallback).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Bitcoin Onchain Section
|
||||
|
||||
NIP-BC (Onchain Zaps) uses Taproot addresses derived from the user's Nostr pubkey to send on-chain Bitcoin zaps. Amethyst's [`OnchainSection.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/OnchainSection.kt:91) shows the user's Taproot address, balance, and a send dialog.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **5.1** Create `www/js/onchain-wallet.mjs` with:
|
||||
- `getTaprootAddress(pubkey)` — derive the Taproot address from the user's Nostr pubkey (NIP-BC spec: tweak the pubkey with a standard script tree)
|
||||
- `fetchOnchainBalance(address)` — query a block explorer API (mempool.space) for the address UTXO balance
|
||||
- `fetchOnchainTransactions(address)` — query mempool.space for address transactions
|
||||
- `sendOnchainZap(recipientPubkey, amountSats)` — build and broadcast an onchain zap (NIP-BC kind 8333 receipt event + Bitcoin tx)
|
||||
- Subscribe to kind 8333 (`OnchainZapEvent`) for incoming/outgoing zap history
|
||||
|
||||
- [ ] **5.2** Onchain UI in `wallet.html`:
|
||||
- Taproot address display with copy button and QR code
|
||||
- Balance display (fetched from mempool.space)
|
||||
- "Send Onchain Zap" button — opens a dialog to select recipient (by npub or address) and amount
|
||||
- Transaction history list — incoming (green) and outgoing (orange) onchain zaps with counterparty info
|
||||
- Public address warning (same as Amethyst: "This address is public — anyone can see your balance")
|
||||
|
||||
- [ ] **5.3** Add onchain zap event subscription in `ndk-worker.js`:
|
||||
- Subscribe to kind 8333 events where `p` tag = user pubkey (incoming) and where author = user pubkey (outgoing)
|
||||
- Broadcast onchain zap events to the page via `onchainZapReceived` / `onchainZapSent` messages
|
||||
|
||||
- [ ] **5.4** Integrate onchain zaps as a payment option in `post-interactions.mjs` — when zapping, offer the choice between Lightning (NWC), Cashu (nutzap), and Onchain (NIP-BC), similar to Amethyst's unified zap chip.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: CLINK Debit Section
|
||||
|
||||
CLINK (NIP-CLINK) is a spend-only debit protocol where the user adds a debit pointer (`ndebit1...`) that authorizes payments against a CLINK service. Amethyst's [`AddClinkDebitWalletScreen.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddClinkDebitWalletScreen.kt:73) shows the add flow.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **6.1** Create `www/js/clink-wallet.mjs` with:
|
||||
- `loadClinkDebits()` — read stored CLINK debit pointers from localStorage (key: `clinkDebits`)
|
||||
- `addClinkDebit(name, ndebitUri)` — parse the `ndebit1...` pointer, validate, store
|
||||
- `removeClinkDebit(id)` — remove a debit pointer
|
||||
- `requestClinkBudget(debitId, amountSats, frequency)` — request a spending budget from the CLINK service
|
||||
- `payViaClinkDebit(debitId, bolt11)` — pay a Lightning invoice via the CLINK debit
|
||||
|
||||
- [ ] **6.2** CLINK UI in `wallet.html`:
|
||||
- Debit list — each row shows name, "spend-only" badge, budget info, delete button
|
||||
- "Add CLINK Debit" form — name input + `ndebit1...` pointer input
|
||||
- Budget dialog — set spending limit (amount + frequency: one-time / daily / weekly / monthly)
|
||||
- No balance display (CLINK debits are spend-only, no balance to show — same as Amethyst)
|
||||
|
||||
- [ ] **6.3** Integrate CLINK as a zap payment source in `post-interactions.mjs` — when the user has a CLINK debit configured, offer it as a payment option for Lightning zaps.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Unified "Add Wallet" Flow
|
||||
|
||||
- [ ] **7.1** Add an "Add Wallet" button/card at the bottom of the wallet list that opens a modal with three choices (matching Amethyst's [`AddWalletScreen.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/AddWalletScreen.kt:64)):
|
||||
|
||||
| Option | Icon | Description |
|
||||
|--------|------|-------------|
|
||||
| Cashu Wallet | Cashew nut | Create a NIP-60 ecash wallet with mints |
|
||||
| NWC Connection | Lightning bolt | Connect a Lightning wallet via NIP-47 |
|
||||
| CLINK Debit | Debit card | Add a spend-only CLINK debit pointer |
|
||||
|
||||
(Bitcoin onchain is always available — no "add" needed, the Taproot address is derived from the pubkey automatically.)
|
||||
|
||||
- [ ] **7.2** Each choice opens the corresponding add-form (reuse the forms built in Phases 3–6).
|
||||
|
||||
- [ ] **7.3** Default wallet selection — let the user set which wallet is the default for zaps (a star/radio button on each wallet card, matching Amethyst's `setDefaultWallet`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Unified Zap Routing
|
||||
|
||||
- [ ] **8.1** Update [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs:1585) zap handler to use a unified payment router that tries rails in priority order:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User clicks Zap] --> B{Recipient has nutzap mint list?}
|
||||
B -->|Yes| C{Sender has Cashu wallet with matching mint?}
|
||||
C -->|Yes| D[Send Cashu Nutzap]
|
||||
C -->|No| E{Sender has NWC or CLINK?}
|
||||
B -->|No| E
|
||||
E -->|NWC| F[Pay LN invoice via NWC]
|
||||
E -->|CLINK| G[Pay LN invoice via CLINK]
|
||||
E -->|None| H[Show invoice for external wallet]
|
||||
F --> I{Recipient supports onchain zaps?}
|
||||
G --> I
|
||||
H --> I
|
||||
I -->|Yes| J[Offer onchain zap alternative]
|
||||
I -->|No| K[Done]
|
||||
D --> K
|
||||
J --> K
|
||||
```
|
||||
|
||||
- [ ] **8.2** Add a zap method selector UI (like Amethyst's zap chip) that shows available rails for the current recipient and lets the user choose.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Worker Updates
|
||||
|
||||
- [ ] **9.1** Add NWC message handlers to `ndk-worker.js` (Phase 4.2)
|
||||
- [ ] **9.2** Add onchain zap subscription to `ndk-worker.js` (Phase 5.3)
|
||||
- [ ] **9.3** Add CLINK payment request handler to `ndk-worker.js` (Phase 6.3)
|
||||
- [ ] **9.4** Add `walletInit` expansion — the existing `walletInit` should also load NWC wallets and onchain state, not just Cashu
|
||||
- [ ] **9.5** Add new worker-to-page events: `nwcBalanceUpdated`, `nwcPaymentResult`, `onchainZapReceived`, `onchainZapSent`, `clinkPaymentResult`
|
||||
- [ ] **9.6** Update [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs:454) to dispatch the new worker events to the page
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Migration & Cleanup
|
||||
|
||||
- [ ] **10.1** Redirect `cashu.html` → `wallet.html` (replace content with a `<meta http-equiv="refresh">` redirect or a JS redirect)
|
||||
- [ ] **10.2** Update sidenav in `index.html` — change `cashu` entry to `wallet`
|
||||
- [ ] **10.3** Update any internal links that point to `cashu.html` (search all HTML files)
|
||||
- [ ] **10.4** Update the footer balance display (currently in `relay-ui.mjs`) to show the unified wallet balance (Cashu + NWC + Onchain)
|
||||
- [ ] **10.5** Keep `cashu-wallet.mjs` as-is (it's the Cashu controller, reused by `wallet.html`)
|
||||
- [ ] **4.3** Add a "Zap via Lightning" info note in the Withdraw panel explaining that this melts ecash to pay a Lightning invoice — same mechanism used for Lightning zaps.
|
||||
|
||||
---
|
||||
|
||||
## NIP Compliance Reference
|
||||
|
||||
| Rail | NIP | Kind(s) | Status in this project |
|
||||
|------|-----|---------|----------------------|
|
||||
| Cashu Wallet | NIP-60 | 17375 (wallet), 7375 (token), 7376 (tx), 375 (backup) | **Bug: content format non-standard** — fixed in Phase 1 |
|
||||
| Cashu Nutzap | NIP-61 | 10019 (mint list), 9321 (nutzap) | Working, but depends on 17375 fix for privkey |
|
||||
| NWC | NIP-47 | 23194 (request/response) | **Not implemented** — Phase 4 |
|
||||
| Onchain Zap | NIP-BC | 8333 (onchain zap receipt) | **Not implemented** — Phase 5 |
|
||||
| CLINK Debit | NIP-CLINK | ndebit1 pointer | **Not implemented** — Phase 6 |
|
||||
| Rail | NIP | Kind(s) | Status |
|
||||
|------|-----|---------|--------|
|
||||
| Cashu Wallet | NIP-60 | 17375, 7375, 7376, 375 | ✅ Fixed and working |
|
||||
| Cashu Nutzap | NIP-61 | 10019, 9321 | ✅ Working |
|
||||
| Lightning Zap | NIP-57 | 9734, 9735 | ✅ Working (via Cashu melt) |
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Modify
|
||||
## Key Files
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | Fix 17375 content format (Phase 1), add NWC/onchain/CLINK handlers (Phases 4-6) |
|
||||
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | Add new worker event dispatchers (Phase 9.6) |
|
||||
| [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) | No changes (reused as-is) |
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Unified zap routing (Phase 8) |
|
||||
| [`www/index.html`](www/index.html) | Sidenav entry update (Phase 10.2) |
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add zap capability badges to interaction bar, pass selected rail to zap handler |
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()`, update `promptZapDetails` with rail selector |
|
||||
| [`www/cashu.html`](www/cashu.html) | Minor UI polish (title, info notes) |
|
||||
| [`www/index.html`](www/index.html) | Sidenav label: "CASHU" → "WALLET" |
|
||||
|
||||
## Key Files to Create
|
||||
### Files reused as-is
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `www/wallet.html` | New unified wallet page |
|
||||
| `www/js/wallet-ui.mjs` | Page orchestrator |
|
||||
| `www/js/nwc-wallet.mjs` | NWC wallet management |
|
||||
| `www/js/onchain-wallet.mjs` | Bitcoin onchain section |
|
||||
| `www/js/clink-wallet.mjs` | CLINK debit management |
|
||||
| File | Why no changes needed |
|
||||
|------|----------------------|
|
||||
| [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) | Controller already wraps all worker functions |
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | All wallet functions already exist (payInvoice, sendNutzap, fetchMintList) |
|
||||
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | All wallet exports already exist |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
The phases are ordered by dependency and impact:
|
||||
1. **Phase 1** (DONE) — NIP-60 compliance fix
|
||||
2. **Phase 2** (zap capability indicators) — show what the recipient can receive in the feed
|
||||
3. **Phase 3** (rail selector) — let users choose nutzap vs Lightning melt in the zap dialog
|
||||
4. **Phase 4** (UI polish) — minor label/info changes
|
||||
|
||||
1. **Phase 1** (NIP-60 fix) — do this first; it's a bug fix that benefits the existing `cashu.html` immediately
|
||||
2. **Phase 2** (page shell) — creates the container for everything else
|
||||
3. **Phase 3** (Cashu migration) — moves existing working functionality into the new page
|
||||
4. **Phase 4** (NWC) — adds the first new rail (Lightning)
|
||||
5. **Phase 5** (Onchain) — adds Bitcoin onchain zaps
|
||||
6. **Phase 6** (CLINK) — adds CLINK debit support
|
||||
7. **Phase 7** (Add wallet flow) — ties the rails together with a unified add modal
|
||||
8. **Phase 8** (Unified zap routing) — integrates all rails into the zap flow
|
||||
9. **Phase 9** (Worker updates) — can be done incrementally alongside Phases 4-6
|
||||
10. **Phase 10** (Migration & cleanup) — final step after everything works
|
||||
No new files needed. No new infrastructure. Just UI improvements on top of the existing working zap flow.
|
||||
|
||||
@@ -637,6 +637,7 @@
|
||||
<button id="btnCheckProofs" class="btn cashuBtn" type="button" style="margin-bottom: 10px;">Check Proofs with Mints</button>
|
||||
<div id="divCheckProofsResult">No proof check run yet.</div>
|
||||
<button id="btnRefreshWallet" class="btn cashuBtn" type="button">Refresh Wallet State</button>
|
||||
<button id="btnRepublishWallet" class="btn cashuBtn" type="button" style="margin-top: 5px;">Republish Wallet (kind 17375)</button>
|
||||
</div>
|
||||
|
||||
<div id="divZapsSection" class="sidenavSection">
|
||||
@@ -728,7 +729,7 @@
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3';
|
||||
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=nip60-republish-1';
|
||||
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
@@ -2348,6 +2349,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
const btnRepublishWallet = document.getElementById('btnRepublishWallet');
|
||||
btnRepublishWallet?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!walletController?.hasWallet?.()) {
|
||||
setStatus('No wallet to republish', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Republishing wallet event (kind 17375)...');
|
||||
await walletController.republishWallet();
|
||||
await refreshAllWalletViews();
|
||||
setStatus('Wallet republished successfully');
|
||||
} catch (error) {
|
||||
console.error('[cashu.html] Republish wallet failed:', error);
|
||||
setStatus(error.message || 'Republish failed', true);
|
||||
}
|
||||
});
|
||||
|
||||
btnSaveZapsSettings?.addEventListener('click', async () => {
|
||||
try {
|
||||
const amount = Number(inpZapDefaultAmount?.value || 0);
|
||||
|
||||
@@ -1388,6 +1388,68 @@ a.nostr-embed-preview-text:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Zap rail selector (Phase 4 — nutzap vs Lightning) */
|
||||
.zap-rail-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.zap-rail-option {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--muted-color);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zap-rail-option.active {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.zap-rail-single {
|
||||
display: block;
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--accent-color);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zap-selector-balance {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.zap-selector-warning {
|
||||
font-size: 12px;
|
||||
color: var(--accent-color);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zap-selector-mint-info {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.zap-selector-error {
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.zap-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ************************************************************************* */
|
||||
/* AUTHOR HEADER */
|
||||
/* ************************************************************************* */
|
||||
|
||||
@@ -693,6 +693,7 @@
|
||||
const eventRelayPresence = new Map(); // eventKey -> Map(relayUrl, boolean)
|
||||
const eventRelayPublishState = new Map(); // eventKey -> Map(relayUrl, 'idle'|'checking'|'publishing'|'failed')
|
||||
let subscribedRelayUrls = [];
|
||||
let readableRelayUrls = []; // read/both relays only (excludes write-only) for Refresh Kind queries
|
||||
let refreshKindDebugInFlight = false;
|
||||
const autoRefreshedKinds = new Set();
|
||||
const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
@@ -1027,12 +1028,26 @@
|
||||
? rows.filter((r) => r?.fromRelayList === true)
|
||||
: rows;
|
||||
|
||||
// subscribedRelayUrls includes all relays (read, write, both) so that
|
||||
// write-only relays still appear as publish-target chips and are valid
|
||||
// targets for the "publish to missing relays" flow.
|
||||
const urls = [...new Set(scopedRows
|
||||
.map((r) => String(r?.url || '').trim())
|
||||
.filter(Boolean))];
|
||||
|
||||
subscribedRelayUrls = urls;
|
||||
|
||||
// readableRelayUrls excludes write-only relays (NIP-65 type === 'write')
|
||||
// so the Refresh Kind query loop never tries to READ from a write-only
|
||||
// relay. Falls back to the full list when type info is unavailable.
|
||||
const hasTypeinfo = scopedRows.some((r) => r?.type);
|
||||
readableRelayUrls = hasTypeinfo
|
||||
? [...new Set(scopedRows
|
||||
.filter((r) => r?.type !== 'write')
|
||||
.map((r) => String(r?.url || '').trim())
|
||||
.filter(Boolean))]
|
||||
: [...urls];
|
||||
|
||||
for (const relayMap of eventRelayPresence.values()) {
|
||||
for (const url of subscribedRelayUrls) {
|
||||
if (!relayMap.has(url)) relayMap.set(url, false);
|
||||
@@ -1815,8 +1830,13 @@
|
||||
});
|
||||
|
||||
setSubscribedRelayUrlsFromRelayData(relayData);
|
||||
const relayUrls = [...subscribedRelayUrls];
|
||||
console.log('[event-management] refresh:relayUrls scoped for query', relayUrls);
|
||||
// Use readableRelayUrls (excludes write-only relays) so Refresh Kind
|
||||
// never tries to READ from a write-only relay like sendit.nosflare.com.
|
||||
const relayUrls = [...readableRelayUrls];
|
||||
console.log('[event-management] refresh:relayUrls scoped for query (read-only)', relayUrls, {
|
||||
subscribedRelayUrls: subscribedRelayUrls,
|
||||
readableRelayUrls: readableRelayUrls
|
||||
});
|
||||
|
||||
if (!relayUrls.length) {
|
||||
divKindsStatus.textContent = `Kind ${targetKind}: no relays found in kind 10002 relay list`;
|
||||
|
||||
729
www/feed-old.html
Normal file
729
www/feed-old.html
Normal file
@@ -0,0 +1,729 @@
|
||||
<!DOCTYPE html>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FEED</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
<link rel="stylesheet" href="./css/post-composer.css" />
|
||||
<link rel="stylesheet" href="./css/dot-menu.css" />
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark-mode');
|
||||
if (document.body) document.body.classList.add('dark-mode');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||||
<script src="./js/vendor/svg.min.js"></script>
|
||||
|
||||
<style>
|
||||
#divBody {
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
align-content: flex-start !important;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
#divHint,
|
||||
#divFeed,
|
||||
#btnSeeMore {
|
||||
width: 80%;
|
||||
min-width: 300px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
#divHint {
|
||||
text-align: center;
|
||||
color: var(--muted-color);
|
||||
font-size: 80%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
#divFeed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#btnSeeMore {
|
||||
display: none;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.divPostItem .post-composer-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="divSvgHam" class="divHeaderButtons"></div>
|
||||
|
||||
<div id="divHeader">
|
||||
<div id="divHeaderFlexLeft"></div>
|
||||
<div id="divHeaderFlexCenter">
|
||||
<div class="divHeaderText">FEED</div>
|
||||
</div>
|
||||
<div id="divHeaderFlexRight"></div>
|
||||
</div>
|
||||
|
||||
<div id="divBody">
|
||||
<div id="divHint">Loading follows…</div>
|
||||
<div id="divFeed"></div>
|
||||
<button id="btnSeeMore" class="btn">See More</button>
|
||||
</div>
|
||||
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<button id="commentsToggleButton">Comments</button>
|
||||
</div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
</div>
|
||||
|
||||
<div id="divSideNav">
|
||||
<div id="divSideNavHeader"></div>
|
||||
<div id="divSideNavBody">
|
||||
<div id="divFiles"></div>
|
||||
<div id="divFeedSettings" class="sidenavSection">
|
||||
<div class="sidenavSectionTitle">Feed</div>
|
||||
<label class="sidenavRowToggle" for="chkAutoplayVideos">
|
||||
<span>Autoplay videos</span>
|
||||
<input id="chkAutoplayVideos" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="divAiSection" class="sidenavSection">
|
||||
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||||
<div id="divAiList" class="sidenavSectionList">
|
||||
<div id="divAiProvidersList">No saved providers yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="divRelaySection">
|
||||
<div id="divRelaySectionTitle">リレー</div>
|
||||
<div id="divRelayList">Loading relays...</div>
|
||||
</div>
|
||||
|
||||
<div id="divBlossomSection">
|
||||
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||||
<div id="divBlossomList">Loading blossom servers...</div>
|
||||
</div>
|
||||
|
||||
<div id="divVersionBar">
|
||||
<span id="versionDisplay">loading...</span>
|
||||
<div id="divVersionBarButtons">
|
||||
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||||
<div id="themeToggleHamburgerContainer"></div>
|
||||
</button>
|
||||
<button id="logoutButton" title="Logout">
|
||||
<div id="logoutHamburgerContainer"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
initNDKPage,
|
||||
getPubkey,
|
||||
injectHeaderAvatar,
|
||||
subscribe,
|
||||
disconnect,
|
||||
getVersion,
|
||||
queryCache,
|
||||
ndkFetchEvents,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
publishEvent,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onUserSettings,
|
||||
ensureMuteListLoaded,
|
||||
isEventMuted,
|
||||
addMute
|
||||
} from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { initPostCards } from './js/post-interactions2.mjs';
|
||||
import { mountComposer } from './js/post-composer.mjs';
|
||||
|
||||
const INITIAL_POSTS_LOAD = 10;
|
||||
const SEE_MORE_INCREMENT = 20;
|
||||
const MIN_BOOTSTRAP_POSTS = 10;
|
||||
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
|
||||
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
|
||||
|
||||
let currentPubkey = null;
|
||||
let updateIntervalId = null;
|
||||
|
||||
let unsubscribeUserSettings = null;
|
||||
|
||||
let hamburgerInstance = null;
|
||||
let logoutHamburger = null;
|
||||
let themeToggleHamburger = null;
|
||||
let isDarkMode = false;
|
||||
let isNavOpen = false;
|
||||
|
||||
const posts = [];
|
||||
const postIds = new Set();
|
||||
const renderedPostIds = new Set();
|
||||
let displayCount = INITIAL_POSTS_LOAD;
|
||||
|
||||
let followedPubkeys = [];
|
||||
const feedPubkeys = new Set();
|
||||
|
||||
const divBody = document.getElementById('divBody');
|
||||
const divSideNav = document.getElementById('divSideNav');
|
||||
const divFooterRight = document.getElementById('divFooterRight');
|
||||
const divFeed = document.getElementById('divFeed');
|
||||
const divHint = document.getElementById('divHint');
|
||||
const btnSeeMore = document.getElementById('btnSeeMore');
|
||||
const chkAutoplayVideos = document.getElementById('chkAutoplayVideos');
|
||||
|
||||
let autoplayVideosEnabled = false;
|
||||
let liveFeedSubscriptionKey = '';
|
||||
|
||||
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
|
||||
|
||||
let isBootstrappingFeed = false;
|
||||
let hasBootstrappedFeed = false;
|
||||
const bufferedFeedEvents = [];
|
||||
|
||||
function toNostrNoteRef(postId) {
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.noteEncode
|
||||
? window.NostrTools.nip19.noteEncode(postId)
|
||||
: postId;
|
||||
} catch (_) {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
function focusInlineComposer(hostEl, content = '') {
|
||||
if (!hostEl) return false;
|
||||
hostEl.innerText = content || '';
|
||||
hostEl.focus();
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
hostEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
window.requestAnimationFrame(() => {
|
||||
hostEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getOrCreateInlineComposer(postId, postEl) {
|
||||
if (!postId || !postEl) return null;
|
||||
|
||||
const existing = inlineComposerInstances.get(postId);
|
||||
if (existing?.hostEl?.isConnected && existing?.instance) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const hostEl = document.createElement('div');
|
||||
hostEl.className = 'inlinePostComposerInput';
|
||||
hostEl.dataset.inlineComposerFor = postId;
|
||||
postEl.appendChild(hostEl);
|
||||
|
||||
const entry = {
|
||||
hostEl,
|
||||
tags: [],
|
||||
instance: null
|
||||
};
|
||||
|
||||
const instance = mountComposer(hostEl, {
|
||||
currentPubkey,
|
||||
followedProfiles: [],
|
||||
showUploadIcon: true,
|
||||
showPreview: true,
|
||||
autoHideOnSubmit: true,
|
||||
hideOnEscape: true,
|
||||
onSubmit: async (content) => {
|
||||
const text = String(content || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
await publishEvent({
|
||||
kind: 1,
|
||||
content: text,
|
||||
tags: entry.tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
entry.instance = instance;
|
||||
inlineComposerInstances.set(postId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function handleComposerCommentIntent({ postId, postPubkey }) {
|
||||
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
||||
if (!postEl) return false;
|
||||
|
||||
const entry = getOrCreateInlineComposer(postId, postEl);
|
||||
if (!entry) return false;
|
||||
entry.tags = [
|
||||
['e', postId, '', 'reply'],
|
||||
['p', postPubkey]
|
||||
];
|
||||
if (entry.instance?.show) entry.instance.show();
|
||||
return focusInlineComposer(entry.hostEl, '');
|
||||
}
|
||||
|
||||
function handleComposerQuoteIntent({ postId, postPubkey }) {
|
||||
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
||||
if (!postEl) return false;
|
||||
|
||||
const noteRef = toNostrNoteRef(postId);
|
||||
const entry = getOrCreateInlineComposer(postId, postEl);
|
||||
if (!entry) return false;
|
||||
entry.tags = [
|
||||
['e', postId, '', 'mention'],
|
||||
['p', postPubkey],
|
||||
['q', postId]
|
||||
];
|
||||
if (entry.instance?.show) entry.instance.show();
|
||||
return focusInlineComposer(entry.hostEl, `nostr:${noteRef}`);
|
||||
}
|
||||
|
||||
async function handleMuteIntent({ eventData }) {
|
||||
const targetPubkey = String(eventData?.pubkey || '').trim();
|
||||
if (!targetPubkey || targetPubkey === currentPubkey) return;
|
||||
|
||||
const ok = window.confirm(`Mute ${targetPubkey.slice(0, 8)}…${targetPubkey.slice(-4)}?`);
|
||||
if (!ok) return;
|
||||
|
||||
await addMute('p', targetPubkey, false);
|
||||
|
||||
for (let i = posts.length - 1; i >= 0; i -= 1) {
|
||||
if (posts[i]?.pubkey === targetPubkey) {
|
||||
postIds.delete(posts[i]?.id);
|
||||
posts.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
renderFeed();
|
||||
}
|
||||
|
||||
const cards = initPostCards({
|
||||
subscribe,
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
ndkFetchEvents,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onCommentIntent: handleComposerCommentIntent,
|
||||
onQuoteIntent: handleComposerQuoteIntent,
|
||||
onMuteIntent: handleMuteIntent
|
||||
});
|
||||
|
||||
function initHamburgerMenu() {
|
||||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
|
||||
function openNav() {
|
||||
divSideNav.style.zIndex = 3;
|
||||
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
|
||||
isNavOpen = true;
|
||||
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
|
||||
|
||||
if (!logoutHamburger) {
|
||||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||||
size: 24,
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
logoutHamburger.animateTo('x');
|
||||
}
|
||||
|
||||
if (!themeToggleHamburger) {
|
||||
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
||||
size: 24,
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||||
}
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
divSideNav.style.width = '0vw';
|
||||
divSideNav.style.zIndex = -1;
|
||||
isNavOpen = false;
|
||||
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
|
||||
function toggleNav() {
|
||||
isNavOpen ? closeNav() : openNav();
|
||||
}
|
||||
|
||||
function isOriginalPost(event) {
|
||||
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
|
||||
if (eTags.length === 0) return true;
|
||||
return eTags.every(t => t[3] === 'mention');
|
||||
}
|
||||
|
||||
function applyVideoAutoplaySetting(enabled) {
|
||||
autoplayVideosEnabled = !!enabled;
|
||||
if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled;
|
||||
|
||||
const videoEls = Array.from(divFeed.querySelectorAll('video'));
|
||||
videoEls.forEach((videoEl) => {
|
||||
videoEl.controls = true;
|
||||
videoEl.playsInline = true;
|
||||
videoEl.setAttribute('playsinline', '');
|
||||
videoEl.preload = 'metadata';
|
||||
|
||||
if (autoplayVideosEnabled) {
|
||||
videoEl.autoplay = true;
|
||||
videoEl.muted = true;
|
||||
videoEl.setAttribute('autoplay', '');
|
||||
videoEl.setAttribute('muted', '');
|
||||
const playPromise = videoEl.play?.();
|
||||
if (playPromise?.catch) playPromise.catch(() => {});
|
||||
} else {
|
||||
videoEl.autoplay = false;
|
||||
videoEl.muted = false;
|
||||
videoEl.removeAttribute('autoplay');
|
||||
videoEl.removeAttribute('muted');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySettings(settings) {
|
||||
const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay;
|
||||
applyVideoAutoplaySetting(nextAutoplayEnabled);
|
||||
}
|
||||
|
||||
async function persistAutoplaySetting(enabled) {
|
||||
try {
|
||||
await patchUserSettings({
|
||||
feed: {
|
||||
videoAutoplay: !!enabled
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
function upsertFeedPost(evt) {
|
||||
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
|
||||
if (isEventMuted(evt)) return false;
|
||||
postIds.add(evt.id);
|
||||
posts.push(evt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderFeed() {
|
||||
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
const toShow = posts.slice(0, displayCount);
|
||||
|
||||
divFeed.innerHTML = '';
|
||||
renderedPostIds.clear();
|
||||
|
||||
toShow.forEach((post) => {
|
||||
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
|
||||
divFeed.appendChild(postEl);
|
||||
renderedPostIds.add(post.id);
|
||||
});
|
||||
|
||||
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
|
||||
|
||||
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
|
||||
divHint.textContent = followedPubkeys.length > 0
|
||||
? `${posts.length} posts from ${followedPubkeys.length} follows`
|
||||
: 'Follow users to populate your feed.';
|
||||
divFooterRight.textContent = `${posts.length} posts`;
|
||||
}
|
||||
|
||||
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
const filters = { kinds: [1], authors, limit };
|
||||
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
|
||||
filters.since = since;
|
||||
}
|
||||
|
||||
const cached = await queryCache(filters).catch(() => []);
|
||||
cached.forEach((evt1) => {
|
||||
upsertFeedPost(evt1);
|
||||
});
|
||||
|
||||
if (renderAfterCache && posts.length > 0) {
|
||||
renderFeed();
|
||||
}
|
||||
|
||||
void ndkFetchEvents(filters).then((fresh) => {
|
||||
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
|
||||
upsertFeedPost(evt1);
|
||||
});
|
||||
if (posts.length > 0) {
|
||||
renderFeed();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function bootstrapFeedPosts(authors) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) {
|
||||
hasBootstrappedFeed = true;
|
||||
renderFeed();
|
||||
return;
|
||||
}
|
||||
|
||||
isBootstrappingFeed = true;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await Promise.allSettled(
|
||||
FEED_BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
|
||||
fetchFeedWindow(authors, {
|
||||
since: now - windowSeconds,
|
||||
limit: FEED_BOOTSTRAP_WINDOW_LIMIT,
|
||||
renderAfterCache: true
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (posts.length < MIN_BOOTSTRAP_POSTS) {
|
||||
await fetchFeedWindow(authors, {
|
||||
since: 0,
|
||||
limit: FEED_BOOTSTRAP_WINDOW_LIMIT * 2,
|
||||
renderAfterCache: true
|
||||
});
|
||||
}
|
||||
|
||||
bufferedFeedEvents.forEach((evt1) => {
|
||||
if (feedPubkeys.has(evt1.pubkey)) {
|
||||
upsertFeedPost(evt1);
|
||||
}
|
||||
});
|
||||
bufferedFeedEvents.length = 0;
|
||||
|
||||
isBootstrappingFeed = false;
|
||||
hasBootstrappedFeed = true;
|
||||
renderFeed();
|
||||
}
|
||||
|
||||
function ensureLiveFeedSubscription() {
|
||||
const authors = Array.from(feedPubkeys).filter(Boolean);
|
||||
if (authors.length === 0) return;
|
||||
|
||||
const key = authors.slice().sort().join(',');
|
||||
if (key === liveFeedSubscriptionKey) return;
|
||||
|
||||
const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
|
||||
|
||||
subscribe(
|
||||
{ kinds: [1], authors, since },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
|
||||
liveFeedSubscriptionKey = key;
|
||||
console.log('[feed.html] Live feed subscription active', { authors: authors.length, since });
|
||||
}
|
||||
|
||||
async function ingestContactList(evt) {
|
||||
if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
|
||||
const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || [];
|
||||
followedPubkeys = pTags.map(tag => tag[1]);
|
||||
|
||||
const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk));
|
||||
newAuthors.forEach((pk) => feedPubkeys.add(pk));
|
||||
|
||||
ensureLiveFeedSubscription();
|
||||
|
||||
const allAuthors = Array.from(feedPubkeys);
|
||||
|
||||
if (!hasBootstrappedFeed) {
|
||||
await bootstrapFeedPosts(allAuthors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newAuthors.length > 0) {
|
||||
try {
|
||||
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
|
||||
renderFeed();
|
||||
} catch (_) {
|
||||
renderFeed();
|
||||
}
|
||||
} else {
|
||||
renderFeed();
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (updateIntervalId) {
|
||||
clearInterval(updateIntervalId);
|
||||
updateIntervalId = null;
|
||||
}
|
||||
cards.destroy();
|
||||
disconnect();
|
||||
if (window.NOSTR_LOGIN_LITE?.logout) {
|
||||
await window.NOSTR_LOGIN_LITE.logout();
|
||||
}
|
||||
if (unsubscribeUserSettings) {
|
||||
unsubscribeUserSettings();
|
||||
unsubscribeUserSettings = null;
|
||||
}
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
location.reload(true);
|
||||
}
|
||||
|
||||
async function updateFooter() {
|
||||
try {
|
||||
await updateFooterRelayStatus();
|
||||
await updateSidenavRelaySection();
|
||||
await updateBlossomSection();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
try {
|
||||
const versionInfo = await getVersion();
|
||||
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
|
||||
|
||||
initHamburgerMenu();
|
||||
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
|
||||
|
||||
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
|
||||
isDarkMode = !isDarkMode;
|
||||
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||||
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
||||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||
if (themeToggleHamburger) {
|
||||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logoutButton')?.addEventListener('click', logout);
|
||||
|
||||
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
|
||||
const nextVisible = !cards.getCommentsVisible();
|
||||
cards.setCommentsVisible(nextVisible);
|
||||
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
|
||||
});
|
||||
|
||||
btnSeeMore.addEventListener('click', () => {
|
||||
displayCount += SEE_MORE_INCREMENT;
|
||||
renderFeed();
|
||||
});
|
||||
|
||||
chkAutoplayVideos?.addEventListener('change', async () => {
|
||||
const nextEnabled = !!chkAutoplayVideos.checked;
|
||||
applyVideoAutoplaySetting(nextEnabled);
|
||||
await persistAutoplaySetting(nextEnabled);
|
||||
});
|
||||
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
await injectHeaderAvatar(currentPubkey);
|
||||
|
||||
await ensureMuteListLoaded();
|
||||
|
||||
try {
|
||||
const settings = await getUserSettings();
|
||||
applySettings(settings || {});
|
||||
} catch (error) {
|
||||
console.warn('[feed.html] Failed to load user settings:', error?.message || error);
|
||||
applySettings({});
|
||||
}
|
||||
|
||||
if (!unsubscribeUserSettings) {
|
||||
unsubscribeUserSettings = onUserSettings((settings) => {
|
||||
applySettings(settings || {});
|
||||
});
|
||||
}
|
||||
|
||||
initFooterRelayStatus();
|
||||
initSidenavRelaySection();
|
||||
await initBlossomSection();
|
||||
initAiSectionWithLocalConfig();
|
||||
await updateFooter();
|
||||
updateIntervalId = setInterval(updateFooter, 1000);
|
||||
|
||||
window.addEventListener('ndkRelayActivity', (e) => {
|
||||
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEvent', async (e) => {
|
||||
const evt = e.detail;
|
||||
if (!evt) return;
|
||||
|
||||
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
|
||||
await ingestContactList(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt)) {
|
||||
if (isBootstrappingFeed) {
|
||||
bufferedFeedEvents.push(evt);
|
||||
return;
|
||||
}
|
||||
if (!upsertFeedPost(evt)) return;
|
||||
renderFeed();
|
||||
}
|
||||
});
|
||||
|
||||
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
|
||||
const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []);
|
||||
if (cachedContactEvents.length > 0) {
|
||||
const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
|
||||
await ingestContactList(latest);
|
||||
}
|
||||
|
||||
if (feedPubkeys.size === 0) {
|
||||
divHint.textContent = 'Follow users to populate your feed.';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[feed.html] Initialization failed:', error);
|
||||
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -87,9 +87,7 @@
|
||||
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<button id="commentsToggleButton">Comments</button>
|
||||
</div>
|
||||
<div id="divFooterCenter" class="divFooterBox"></div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
</div>
|
||||
@@ -157,6 +155,8 @@
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetBalance,
|
||||
walletGetMints,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -281,18 +281,10 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
return entry;
|
||||
}
|
||||
|
||||
function handleComposerCommentIntent({ postId, postPubkey }) {
|
||||
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
||||
if (!postEl) return false;
|
||||
|
||||
const entry = getOrCreateInlineComposer(postId, postEl);
|
||||
if (!entry) return false;
|
||||
entry.tags = [
|
||||
['e', postId, '', 'reply'],
|
||||
['p', postPubkey]
|
||||
];
|
||||
if (entry.instance?.show) entry.instance.show();
|
||||
return focusInlineComposer(entry.hostEl, '');
|
||||
function handleComposerCommentIntent({ postId }) {
|
||||
if (!postId) return false;
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleComposerQuoteIntent({ postId, postPubkey }) {
|
||||
@@ -335,11 +327,14 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
ndkFetchEvents,
|
||||
queryCache,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetBalance,
|
||||
walletGetMints,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -639,12 +634,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
|
||||
document.getElementById('logoutButton')?.addEventListener('click', logout);
|
||||
|
||||
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
|
||||
const nextVisible = !cards.getCommentsVisible();
|
||||
cards.setCommentsVisible(nextVisible);
|
||||
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
|
||||
});
|
||||
|
||||
btnSeeMore.addEventListener('click', () => {
|
||||
displayCount += SEE_MORE_INCREMENT;
|
||||
renderFeed();
|
||||
@@ -720,7 +709,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
|
||||
} catch (error) {
|
||||
console.error('[feed.html] Initialization failed:', error);
|
||||
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
|
||||
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
walletRemoveMint,
|
||||
walletGetTransactions,
|
||||
walletShutdown,
|
||||
walletRepublish,
|
||||
walletPublishMintList,
|
||||
walletFetchMintList,
|
||||
onWalletStatus,
|
||||
@@ -397,6 +398,16 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
|
||||
}
|
||||
}
|
||||
|
||||
async function republishWallet() {
|
||||
await ensureWallet();
|
||||
const result = await walletRepublish();
|
||||
if (Array.isArray(result?.mints)) {
|
||||
mintsCache = uniqueMints(result.mints);
|
||||
}
|
||||
applyBalancePayload(result || {});
|
||||
return { mints: [...mintsCache] };
|
||||
}
|
||||
|
||||
cleanupFns.push(onWalletStatus((payload) => {
|
||||
if (typeof payload?.hasWallet === 'boolean') {
|
||||
hasWalletCache = payload.hasWallet;
|
||||
@@ -431,6 +442,7 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
|
||||
waitForDeposit,
|
||||
getTransactionHistory,
|
||||
publishMintList,
|
||||
republishWallet,
|
||||
fetchRecipientMintList,
|
||||
onWalletEvent,
|
||||
subscribeTransactions,
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function initNDKPage(options = {}) {
|
||||
|
||||
// All pages in www/ directory, so worker path is always the same.
|
||||
// Include explicit worker revision token so updated SharedWorker code is guaranteed to restart.
|
||||
const WORKER_REVISION = 'nip60-tagarray-fix-1';
|
||||
const WORKER_REVISION = 'nip60-lightweight-republish-1';
|
||||
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
|
||||
|
||||
// Initialize NDK SharedWorker (shared across all tabs/pages)
|
||||
@@ -1425,6 +1425,10 @@ export function walletShutdown() {
|
||||
return sendWorkerRequest('walletShutdown', {}, 10000, 'walletShutdown timeout');
|
||||
}
|
||||
|
||||
export function walletRepublish() {
|
||||
return sendWorkerRequest('walletRepublish', {}, 30000, 'walletRepublish timeout');
|
||||
}
|
||||
|
||||
export function walletPublishMintList(relays = [], receiveMints = []) {
|
||||
return sendWorkerRequest('walletPublishMintList', { relays, receiveMints }, 25000, 'walletPublishMintList timeout');
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
promptZapDetails,
|
||||
prepareZapInvoiceForEvent,
|
||||
resolveNutzapSpecForPubkey,
|
||||
resolveZapCapabilities,
|
||||
extractZapAmount as extractZapAmountFromReceipt,
|
||||
extractZapComment as extractZapCommentFromReceipt
|
||||
} from './zaps.mjs';
|
||||
@@ -957,6 +958,8 @@ let publishEventFn = null;
|
||||
let walletPayInvoiceFn = null;
|
||||
let walletSendNutzapFn = null;
|
||||
let walletFetchMintListFn = null;
|
||||
let walletGetMintsFn = null;
|
||||
let walletGetBalanceFn = null;
|
||||
let getRelayDataFn = null;
|
||||
let getUserSettingsFn = null;
|
||||
let patchUserSettingsFn = null;
|
||||
@@ -1079,7 +1082,11 @@ export function renderFooterRow(eventId, eventData, options = {}) {
|
||||
container.appendChild(timeEl);
|
||||
|
||||
footerRow.appendChild(container);
|
||||
|
||||
|
||||
// Async, non-blocking zap capability badges (Phase 3).
|
||||
// Rendered after the bar is in the DOM so feed rendering is never blocked.
|
||||
setTimeout(() => attachZapCapabilityBadges(eventId, eventData.pubkey), 0);
|
||||
|
||||
return footerRow;
|
||||
}
|
||||
|
||||
@@ -1145,6 +1152,10 @@ export function renderInteractionBar(postId, postData, options = {}) {
|
||||
container.appendChild(commentItem);
|
||||
container.appendChild(quoteItem);
|
||||
|
||||
// Async, non-blocking zap capability badges (Phase 3).
|
||||
// Rendered after the bar is in the DOM so feed rendering is never blocked.
|
||||
setTimeout(() => attachZapCapabilityBadges(postId, postData.pubkey), 0);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -1302,6 +1313,135 @@ function updateNutzapCountDisplay(el, state = {}) {
|
||||
el.classList.toggle('has-count', hasCount);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ZAP CAPABILITY BADGES (Phase 3 — feed-upgrade)
|
||||
// =============================================================================
|
||||
//
|
||||
// Small visual indicators appended next to the zap button showing which zap
|
||||
// rails are available for the post's author:
|
||||
// ⚡ (accent) — recipient has a lud16 Lightning address
|
||||
// 🥜 (accent) — recipient has a kind 10019 mint list with a shared mint
|
||||
// 🥜 (muted) — recipient has a kind 10019 but no shared mint with sender
|
||||
//
|
||||
// Badges are rendered asynchronously after the interaction bar is in the DOM
|
||||
// so feed rendering is never blocked. Each badge carries a `title` tooltip
|
||||
// with human-readable details. Colors come exclusively from CSS variables.
|
||||
|
||||
/**
|
||||
* Build a single capability badge element.
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.glyph - emoji to display (e.g. '⚡' or '🥜')
|
||||
* @param {string} opts.colorVar - CSS variable name for color ('--accent-color' | '--muted-color')
|
||||
* @param {string} opts.title - tooltip text
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
function createZapCapabilityBadge({ glyph, colorVar, title }) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'zap-capability-badge';
|
||||
badge.textContent = glyph;
|
||||
badge.title = title;
|
||||
badge.style.color = `var(${colorVar})`;
|
||||
badge.style.fontSize = '75%';
|
||||
badge.style.lineHeight = '1';
|
||||
badge.style.marginLeft = '2px';
|
||||
badge.style.userSelect = 'none';
|
||||
badge.style.pointerEvents = 'none';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
return badge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tooltip text for the nutzap capability.
|
||||
* @param {Object} caps - result from resolveZapCapabilities
|
||||
* @returns {string}
|
||||
*/
|
||||
function nutzapCapabilityTooltip(caps) {
|
||||
if (caps.canNutzap && caps.sharedMints.length > 0) {
|
||||
const first = caps.sharedMints[0];
|
||||
const extra = caps.sharedMints.length > 1
|
||||
? ` (+${caps.sharedMints.length - 1} more)`
|
||||
: '';
|
||||
return `Can nutzap via ${first}${extra}`;
|
||||
}
|
||||
if (caps.hasMintList) {
|
||||
return 'No shared mints — Lightning only';
|
||||
}
|
||||
return 'No nutzap mint list';
|
||||
}
|
||||
|
||||
/**
|
||||
* Append capability badges to an interaction bar for a given post.
|
||||
* Looks up the bar by `data-post-id` so it works regardless of which render
|
||||
* path created it (renderFooterRow or renderInteractionBar).
|
||||
*
|
||||
* @param {string} postId - the event id used as data-post-id
|
||||
* @param {string} pubkey - the post author's pubkey
|
||||
*/
|
||||
function attachZapCapabilityBadges(postId, pubkey) {
|
||||
if (!postId || !pubkey) return;
|
||||
if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') {
|
||||
return; // nothing to resolve with
|
||||
}
|
||||
|
||||
resolveZapCapabilities(pubkey, {
|
||||
fetchProfile,
|
||||
fetchMintList: walletFetchMintListFn || undefined,
|
||||
getSenderMints: walletGetMintsFn || undefined,
|
||||
logPrefix: '[post-interactions][zap-caps]'
|
||||
}).then((caps) => {
|
||||
const bar = document.querySelector(`.divPostInteractions[data-post-id="${postId}"]`);
|
||||
if (!bar) return; // post may have been removed from the DOM
|
||||
|
||||
// Avoid double-rendering if badges already exist.
|
||||
if (bar.querySelector('.zap-capability-badge')) return;
|
||||
|
||||
const zapItem = bar.querySelector('.interaction-item.zap');
|
||||
const anchor = zapItem || bar; // fall back to the bar itself
|
||||
const host = zapItem ? zapItem : bar;
|
||||
|
||||
const badges = [];
|
||||
|
||||
if (caps.canLightning) {
|
||||
badges.push(createZapCapabilityBadge({
|
||||
glyph: '⚡',
|
||||
colorVar: '--accent-color',
|
||||
title: caps.lud16 ? `Lightning: ${caps.lud16}` : 'Lightning zap available'
|
||||
}));
|
||||
}
|
||||
|
||||
if (caps.hasMintList) {
|
||||
const hasShared = caps.canNutzap && caps.sharedMints.length > 0;
|
||||
badges.push(createZapCapabilityBadge({
|
||||
glyph: '🥜',
|
||||
colorVar: hasShared ? '--accent-color' : '--muted-color',
|
||||
title: nutzapCapabilityTooltip(caps)
|
||||
}));
|
||||
}
|
||||
|
||||
if (badges.length === 0) {
|
||||
// Only show an explicit "no capability" indicator on the zap item
|
||||
// itself, and only when we actually had enough info to resolve
|
||||
// (i.e. fetchProfile ran). This keeps the bar uncluttered when the
|
||||
// wallet/ndk isn't ready yet.
|
||||
if (zapItem && caps.lud16 === null && !caps.hasMintList) {
|
||||
const noCap = createZapCapabilityBadge({
|
||||
glyph: '⊘',
|
||||
colorVar: '--muted-color',
|
||||
title: 'No zap capability'
|
||||
});
|
||||
host.appendChild(noCap);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const badge of badges) {
|
||||
host.appendChild(badge);
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn('[post-interactions][zap-caps] attach failed', error?.message || error);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANIMATION HANDLING
|
||||
// =============================================================================
|
||||
@@ -1323,6 +1463,66 @@ function isZapTimeoutError(error) {
|
||||
return msg.includes('timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a zap/melt payment error into a user-friendly message.
|
||||
* Hides raw mint API errors while still being informative.
|
||||
* @param {Error|*} error
|
||||
* @returns {string}
|
||||
*/
|
||||
function friendlyZapErrorMessage(error) {
|
||||
const raw = String(error?.message || error || '').trim();
|
||||
const msg = raw.toLowerCase();
|
||||
|
||||
if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) {
|
||||
return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.';
|
||||
}
|
||||
if (msg.includes('timeout')) {
|
||||
return 'Payment timed out. The mint may be slow or unresponsive.';
|
||||
}
|
||||
if (msg.includes('insufficient') || msg.includes('balance')) {
|
||||
return 'Insufficient Cashu balance for this payment.';
|
||||
}
|
||||
return raw || 'Zap failed.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user's Cashu balance in sats.
|
||||
* Returns null when the wallet function is unavailable or the fetch fails.
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async function fetchWalletBalanceSats() {
|
||||
if (typeof walletGetBalanceFn !== 'function') return null;
|
||||
try {
|
||||
const result = await walletGetBalanceFn();
|
||||
const balance = Number(result?.balance ?? result?.sats ?? result);
|
||||
if (Number.isFinite(balance) && balance >= 0) {
|
||||
return Math.floor(balance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[post-interactions][zap] fetchWalletBalanceSats:failed', error?.message || error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the wallet balance display after a zap.
|
||||
* Re-fetches the balance and dispatches an `ndkWalletBalance` event so any
|
||||
* footer/dialog balance displays can update. Non-throwing.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshWalletBalanceDisplay() {
|
||||
const balanceSats = await fetchWalletBalanceSats();
|
||||
if (balanceSats === null) return;
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ndkWalletBalance', {
|
||||
detail: { balanceSats, source: 'post-interactions-zap' }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('[post-interactions][zap] refreshWalletBalanceDisplay:dispatch-failed', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERACTION HANDLERS
|
||||
// =============================================================================
|
||||
@@ -1587,8 +1787,49 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
throw new Error('Cashu wallet payer is not configured on this page');
|
||||
}
|
||||
|
||||
// --- Resolve zap capabilities (Phase 4) ---------------------------------
|
||||
// Determine which rails (Lightning / Nutzap) are available for this
|
||||
// recipient so the dialog can present a rail selector.
|
||||
let zapCaps = null;
|
||||
try {
|
||||
zapCaps = await resolveZapCapabilities(postPubkey, {
|
||||
fetchProfile,
|
||||
fetchMintList: walletFetchMintListFn || undefined,
|
||||
getSenderMints: walletGetMintsFn || undefined,
|
||||
logPrefix: '[post-interactions][zap-caps]'
|
||||
});
|
||||
} catch (capsError) {
|
||||
console.warn('[post-interactions][zap] handleZapClick:caps-failed', capsError?.message || capsError);
|
||||
}
|
||||
|
||||
const canLightning = Boolean(zapCaps?.canLightning);
|
||||
const canNutzap = Boolean(zapCaps?.canNutzap);
|
||||
const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : [];
|
||||
|
||||
console.log('[post-interactions][zap] handleZapClick:caps', {
|
||||
recipient: String(postPubkey || '').slice(0, 8) + '…',
|
||||
canLightning,
|
||||
canNutzap,
|
||||
sharedMints: sharedMints.length
|
||||
});
|
||||
|
||||
// --- Fetch the user's Cashu balance for the dialog ----------------------
|
||||
let balanceSats = null;
|
||||
if (typeof walletGetBalanceFn === 'function') {
|
||||
try {
|
||||
const balanceResult = await walletGetBalanceFn();
|
||||
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
|
||||
if (Number.isFinite(balance) && balance >= 0) {
|
||||
balanceSats = Math.floor(balance);
|
||||
}
|
||||
} catch (balanceError) {
|
||||
console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve the nutzap spec (still needed for the actual send) ---------
|
||||
let nutzapSpec = null;
|
||||
if (typeof walletFetchMintListFn === 'function') {
|
||||
if (canNutzap && typeof walletFetchMintListFn === 'function') {
|
||||
try {
|
||||
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
|
||||
fetchMintList: walletFetchMintListFn,
|
||||
@@ -1610,7 +1851,9 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
defaultAmountSats: zapDefaults.amountSats,
|
||||
defaultComment: zapDefaults.comment,
|
||||
defaultShouldZap: true,
|
||||
onSaveDefault: saveZapDefaultsToSettings
|
||||
onSaveDefault: saveZapDefaultsToSettings,
|
||||
railOptions: { canLightning, canNutzap, sharedMints },
|
||||
balanceSats
|
||||
});
|
||||
if (!details) {
|
||||
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
|
||||
@@ -1620,7 +1863,8 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
console.log('[post-interactions][zap] handleZapClick:details', {
|
||||
amountSats: details.amountSats,
|
||||
hasComment: Boolean(String(details.comment || '').trim()),
|
||||
shouldZap: Boolean(details.shouldZap)
|
||||
shouldZap: Boolean(details.shouldZap),
|
||||
rail: details.rail
|
||||
});
|
||||
|
||||
const state = getPostState(postId);
|
||||
@@ -1652,9 +1896,55 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') {
|
||||
// --- Pre-flight balance check (Phase 5) --------------------------------
|
||||
// A final safety net right before sending, in case the user ignored the
|
||||
// dialog warning or the balance changed between dialog and send.
|
||||
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
|
||||
const preflightBalance = await fetchWalletBalanceSats();
|
||||
|
||||
if (preflightBalance !== null) {
|
||||
if (preflightBalance < zapAmount) {
|
||||
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
|
||||
console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', {
|
||||
balance: preflightBalance, needed: zapAmount
|
||||
});
|
||||
if (countEl) {
|
||||
countEl.textContent = 'low';
|
||||
countEl.title = msg;
|
||||
setTimeout(() => {
|
||||
updateZapCountDisplay(itemEl, getPostState(postId));
|
||||
}, 2200);
|
||||
}
|
||||
window.alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
// "Barely sufficient" warning: remaining balance would be less than
|
||||
// 10% of the current balance.
|
||||
const remaining = preflightBalance - zapAmount;
|
||||
const tenPercent = Math.floor(preflightBalance * 0.10);
|
||||
if (remaining > 0 && remaining < tenPercent) {
|
||||
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
|
||||
const proceed = window.confirm(confirmMsg);
|
||||
if (!proceed) {
|
||||
console.log('[post-interactions][zap] handleZapClick:declined-low-balance');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Choose the rail based on the user's selection (Phase 4) ------------
|
||||
// Prefer the explicit rail chosen in the dialog. Fall back to capability
|
||||
// detection for backward compatibility when no rail was returned.
|
||||
const useNutzapRail = details.rail === 'nutzap'
|
||||
? (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function')
|
||||
: (details.rail === 'lightning'
|
||||
? false
|
||||
: (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function'));
|
||||
|
||||
if (useNutzapRail) {
|
||||
if (countEl) countEl.textContent = 'send…';
|
||||
console.log('[post-interactions][zap] handleZapClick:sending-nutzap');
|
||||
console.log('[post-interactions][zap] handleZapClick:sending-nutzap', { rail: details.rail });
|
||||
const nutzapResult = await walletSendNutzapFn({
|
||||
amount: details.amountSats,
|
||||
memo: details.comment,
|
||||
@@ -1702,15 +1992,28 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
itemEl.classList.remove('zap-success');
|
||||
updateZapCountDisplay(itemEl, state);
|
||||
}, 1800);
|
||||
|
||||
// --- Post-zap balance refresh (Phase 5) --------------------------------
|
||||
// Refresh the wallet balance display so the user sees their updated
|
||||
// balance after a successful zap. Non-blocking — failures are logged
|
||||
// but never surface to the user since the zap itself succeeded.
|
||||
refreshWalletBalanceDisplay().catch((refreshError) => {
|
||||
console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError);
|
||||
});
|
||||
} catch (error) {
|
||||
const timeoutFailure = isZapTimeoutError(error);
|
||||
const friendlyMessage = friendlyZapErrorMessage(error);
|
||||
console.error('[post-interactions][zap] handleZapClick:failed', error);
|
||||
if (countEl) {
|
||||
countEl.textContent = timeoutFailure ? 'check' : 'fail';
|
||||
countEl.title = friendlyMessage;
|
||||
setTimeout(() => {
|
||||
updateZapCountDisplay(itemEl, getPostState(postId));
|
||||
}, timeoutFailure ? 2800 : 2200);
|
||||
}
|
||||
// Surface a user-friendly error message. Avoids exposing raw mint API
|
||||
// errors while still being informative.
|
||||
window.alert(friendlyMessage);
|
||||
itemEl.classList.remove('zap-success');
|
||||
} finally {
|
||||
clearInterval(zapPulseInterval);
|
||||
@@ -2308,6 +2611,8 @@ export function initInteractions(ndkFunctions = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -2325,6 +2630,8 @@ export function initInteractions(ndkFunctions = {}) {
|
||||
walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null;
|
||||
walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null;
|
||||
walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null;
|
||||
walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null;
|
||||
walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null;
|
||||
getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null;
|
||||
getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null;
|
||||
patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null;
|
||||
|
||||
@@ -14,6 +14,8 @@ export function initPostCards(deps = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS,
|
||||
getUserSettings,
|
||||
@@ -34,6 +36,8 @@ export function initPostCards(deps = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.19",
|
||||
"VERSION_NUMBER": "0.7.19",
|
||||
"BUILD_DATE": "2026-06-25T16:24:48.108Z"
|
||||
"VERSION": "v0.7.32",
|
||||
"VERSION_NUMBER": "0.7.32",
|
||||
"BUILD_DATE": "2026-06-26T00:57:34.005Z"
|
||||
}
|
||||
|
||||
307
www/js/zaps.mjs
307
www/js/zaps.mjs
@@ -249,6 +249,40 @@ function closeActiveZapModal(result, resolver) {
|
||||
resolver(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe insertion into HTML text/attribute content.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
const HTML_ESCAPE_RE = /[&<>"']/g;
|
||||
function escapeHtml(value) {
|
||||
const amp = '&' + 'amp;';
|
||||
const lt = '&' + 'lt;';
|
||||
const gt = '&' + 'gt;';
|
||||
const quot = '&' + 'quot;';
|
||||
const apos = '&' + '#39;';
|
||||
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
|
||||
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the default zap rail based on availability and amount.
|
||||
* - nutzap available and amount < 1000 sats → nutzap
|
||||
* - nutzap available and amount >= 1000 sats → lightning
|
||||
* - nutzap not available → lightning (if available)
|
||||
* - neither available → null
|
||||
* @param {Object} railOptions - { canLightning, canNutzap }
|
||||
* @param {number} amountSats
|
||||
* @returns {'nutzap'|'lightning'|null}
|
||||
*/
|
||||
function chooseDefaultRail(railOptions, amountSats) {
|
||||
const { canLightning = false, canNutzap = false } = railOptions || {};
|
||||
if (canNutzap && Number(amountSats) < 1000) return 'nutzap';
|
||||
if (canLightning) return 'lightning';
|
||||
if (canNutzap) return 'nutzap';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function promptZapDetails(options = {}) {
|
||||
if (activeZapModalEl) {
|
||||
return Promise.resolve(null);
|
||||
@@ -258,9 +292,19 @@ export function promptZapDetails(options = {}) {
|
||||
defaultAmountSats = 21,
|
||||
defaultComment = '',
|
||||
defaultShouldZap = true,
|
||||
onSaveDefault = null
|
||||
onSaveDefault = null,
|
||||
railOptions = null,
|
||||
balanceSats = null
|
||||
} = options;
|
||||
|
||||
// Normalize rail options.
|
||||
const canLightning = Boolean(railOptions?.canLightning);
|
||||
const canNutzap = Boolean(railOptions?.canNutzap);
|
||||
const sharedMints = Array.isArray(railOptions?.sharedMints)
|
||||
? railOptions.sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const hasAnyRail = canLightning || canNutzap;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const initialAmount = Number(defaultAmountSats);
|
||||
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
|
||||
@@ -268,11 +312,44 @@ export function promptZapDetails(options = {}) {
|
||||
: 21;
|
||||
const normalizedDefaultComment = String(defaultComment || '').trim();
|
||||
|
||||
// Resolve the initial rail selection.
|
||||
let selectedRail = hasAnyRail
|
||||
? chooseDefaultRail({ canLightning, canNutzap }, normalizedDefaultAmount)
|
||||
: null;
|
||||
|
||||
const showRailToggle = canLightning && canNutzap;
|
||||
const primarySharedMint = sharedMints[0] || '';
|
||||
|
||||
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
|
||||
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
|
||||
: '';
|
||||
const noRailMessage = !hasAnyRail
|
||||
? `<div class="zap-selector-error">Recipient cannot receive zaps</div>`
|
||||
: '';
|
||||
|
||||
const railToggleHtml = showRailToggle
|
||||
? `<div class="zap-rail-toggle" role="group" aria-label="Zap rail">
|
||||
<button type="button" class="zap-rail-option" data-rail="nutzap">🥜 Nutzap</button>
|
||||
<button type="button" class="zap-rail-option" data-rail="lightning">⚡ Lightning</button>
|
||||
</div>`
|
||||
: (hasAnyRail
|
||||
? `<div class="zap-rail-single zap-rail-option active" data-rail="${selectedRail === 'nutzap' ? 'nutzap' : 'lightning'}">
|
||||
${selectedRail === 'nutzap' ? '🥜 Nutzap' : '⚡ Lightning'}
|
||||
</div>`
|
||||
: '');
|
||||
|
||||
const sharedMintInfoHtml = (canNutzap && primarySharedMint)
|
||||
? `<div class="zap-selector-mint-info" data-rail-info="nutzap">Will send via ${escapeHtml(primarySharedMint)}</div>`
|
||||
: '';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'zap-selector-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
|
||||
<div class="zap-selector-title">Send Zap</div>
|
||||
${noRailMessage}
|
||||
${balanceDisplay}
|
||||
${railToggleHtml}
|
||||
<div class="zap-selector-presets">
|
||||
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
|
||||
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
|
||||
@@ -284,9 +361,11 @@ export function promptZapDetails(options = {}) {
|
||||
Amount (sats)
|
||||
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
|
||||
</label>
|
||||
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
|
||||
${sharedMintInfoHtml}
|
||||
<label class="zap-selector-label">
|
||||
Comment (optional)
|
||||
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${normalizedDefaultComment.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</textarea>
|
||||
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
|
||||
</label>
|
||||
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
|
||||
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
|
||||
@@ -295,7 +374,7 @@ export function promptZapDetails(options = {}) {
|
||||
<div class="zap-selector-actions">
|
||||
<button type="button" class="zap-cancel">Cancel</button>
|
||||
<button type="button" class="zap-save-default">Save as default</button>
|
||||
<button type="button" class="zap-send">Send</button>
|
||||
<button type="button" class="zap-send" ${hasAnyRail ? '' : 'disabled'}>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -307,6 +386,9 @@ export function promptZapDetails(options = {}) {
|
||||
const cancelBtn = overlay.querySelector('.zap-cancel');
|
||||
const enableZapEl = overlay.querySelector('.zap-selector-enable');
|
||||
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
|
||||
const railOptionButtons = Array.from(overlay.querySelectorAll('.zap-rail-option'));
|
||||
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
|
||||
const mintInfoEl = overlay.querySelector('[data-rail-info="nutzap"]');
|
||||
|
||||
const parseCurrentValues = () => {
|
||||
const amountSats = Number(amountEl?.value || 0);
|
||||
@@ -315,11 +397,41 @@ export function promptZapDetails(options = {}) {
|
||||
return { amountSats, comment, shouldZap };
|
||||
};
|
||||
|
||||
const updateRailSelection = (rail) => {
|
||||
if (!rail || !hasAnyRail) return;
|
||||
selectedRail = rail;
|
||||
railOptionButtons.forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset?.rail === rail);
|
||||
});
|
||||
// Show/hide shared mint info based on selected rail.
|
||||
if (mintInfoEl) {
|
||||
mintInfoEl.style.display = (rail === 'nutzap') ? '' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const updateBalanceWarning = () => {
|
||||
if (!warningEl) return;
|
||||
const { amountSats } = parseCurrentValues();
|
||||
const balance = Number(balanceSats);
|
||||
const insufficient = Number.isFinite(balance)
|
||||
&& balance >= 0
|
||||
&& Number.isFinite(amountSats)
|
||||
&& amountSats > balance;
|
||||
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
|
||||
warningEl.style.display = insufficient ? '' : 'none';
|
||||
};
|
||||
|
||||
const selectPreset = (button) => {
|
||||
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
|
||||
if (amountEl && button?.dataset?.amount) {
|
||||
amountEl.value = button.dataset.amount;
|
||||
}
|
||||
// Re-evaluate default rail when amount changes via preset.
|
||||
if (showRailToggle) {
|
||||
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, Number(button?.dataset?.amount || 0));
|
||||
if (newDefault) updateRailSelection(newDefault);
|
||||
}
|
||||
updateBalanceWarning();
|
||||
};
|
||||
|
||||
presetButtons.forEach((button) => {
|
||||
@@ -328,6 +440,22 @@ export function promptZapDetails(options = {}) {
|
||||
|
||||
amountEl?.addEventListener('input', () => {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
// Re-evaluate default rail as the user types.
|
||||
if (showRailToggle) {
|
||||
const { amountSats } = parseCurrentValues();
|
||||
const newDefault = chooseDefaultRail({ canLightning, canNutzap }, amountSats);
|
||||
if (newDefault) updateRailSelection(newDefault);
|
||||
}
|
||||
updateBalanceWarning();
|
||||
});
|
||||
|
||||
railOptionButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const rail = button?.dataset?.rail;
|
||||
if (rail === 'nutzap' || rail === 'lightning') {
|
||||
updateRailSelection(rail);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
saveDefaultBtn?.addEventListener('click', async () => {
|
||||
@@ -362,12 +490,13 @@ export function promptZapDetails(options = {}) {
|
||||
});
|
||||
|
||||
sendBtn?.addEventListener('click', () => {
|
||||
if (!hasAnyRail) return;
|
||||
const { amountSats, comment, shouldZap } = parseCurrentValues();
|
||||
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
|
||||
amountEl?.focus();
|
||||
return;
|
||||
}
|
||||
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
|
||||
closeActiveZapModal({ amountSats, comment, shouldZap, rail: selectedRail }, resolve);
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
|
||||
@@ -390,6 +519,9 @@ export function promptZapDetails(options = {}) {
|
||||
} else {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
// Initialize rail selection and dependent UI state.
|
||||
updateRailSelection(selectedRail);
|
||||
updateBalanceWarning();
|
||||
amountEl?.focus();
|
||||
});
|
||||
}
|
||||
@@ -506,3 +638,170 @@ export function extractZapComment(event) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
|
||||
// =============================================================================
|
||||
//
|
||||
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
|
||||
// available for a given recipient pubkey:
|
||||
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
|
||||
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
|
||||
// one of those mints overlaps with the sender's wallet mints
|
||||
// - sharedMints: the overlapping mint URLs (empty when no overlap)
|
||||
// - nutzapMints: all mints declared on the recipient's kind 10019
|
||||
// - lud16: the recipient's Lightning address (or null)
|
||||
//
|
||||
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
|
||||
// repeated feed renders do not refetch. The cache is best-effort and safe to
|
||||
// clear on page navigation (see clearZapCapabilitiesCache).
|
||||
|
||||
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
|
||||
|
||||
/**
|
||||
* Clear the in-memory zap-capability cache.
|
||||
* Safe to call on page navigation; subsequent calls will refetch.
|
||||
*/
|
||||
export function clearZapCapabilitiesCache() {
|
||||
zapCapCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mint URL for comparison (trim + strip trailing slashes).
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeMintForCompare(url) {
|
||||
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which zap rails are available for a given pubkey.
|
||||
*
|
||||
* @param {string} pubkey - The recipient's pubkey (hex)
|
||||
* @param {Object} options
|
||||
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
|
||||
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
|
||||
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
|
||||
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
|
||||
* @param {string} [options.logPrefix='[zaps]']
|
||||
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
|
||||
*/
|
||||
export async function resolveZapCapabilities(pubkey, options = {}) {
|
||||
const {
|
||||
fetchProfile: fetchProfileFn,
|
||||
fetchMintList,
|
||||
getSenderMints,
|
||||
useCache = true,
|
||||
logPrefix = '[zaps]'
|
||||
} = options;
|
||||
|
||||
const recipient = String(pubkey || '').trim();
|
||||
if (!recipient) {
|
||||
return {
|
||||
canLightning: false,
|
||||
canNutzap: false,
|
||||
sharedMints: [],
|
||||
nutzapMints: [],
|
||||
lud16: null,
|
||||
hasMintList: false
|
||||
};
|
||||
}
|
||||
|
||||
// --- Cache lookup ---------------------------------------------------------
|
||||
const now = Date.now();
|
||||
if (useCache) {
|
||||
const cached = zapCapCache.get(recipient);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.result;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lightning capability (lud16 from kind 0) -----------------------------
|
||||
let lud16 = null;
|
||||
let canLightning = false;
|
||||
if (typeof fetchProfileFn === 'function') {
|
||||
try {
|
||||
const profile = await fetchProfileFn(recipient);
|
||||
const normalized = normalizeLud16(profile?.lud16);
|
||||
if (normalized) {
|
||||
lud16 = normalized;
|
||||
canLightning = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
|
||||
let nutzapMints = [];
|
||||
let sharedMints = [];
|
||||
let canNutzap = false;
|
||||
let hasMintList = false;
|
||||
|
||||
if (typeof fetchMintList === 'function') {
|
||||
try {
|
||||
const mintList = await fetchMintList(recipient);
|
||||
const rawMints = Array.isArray(mintList?.mints)
|
||||
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
nutzapMints = Array.from(new Set(rawMints));
|
||||
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
|
||||
|
||||
if (hasMintList) {
|
||||
// Determine the sender's wallet mints to compute overlap.
|
||||
let senderMints = [];
|
||||
if (typeof getSenderMints === 'function') {
|
||||
try {
|
||||
const senderResult = await getSenderMints();
|
||||
senderMints = Array.isArray(senderResult?.mints)
|
||||
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
} catch (error) {
|
||||
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
if (senderMints.length > 0) {
|
||||
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
|
||||
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
|
||||
canNutzap = sharedMints.length > 0;
|
||||
} else {
|
||||
// No sender wallet mints available yet — cannot confirm a shared mint,
|
||||
// so we do not claim canNutzap. The recipient still has a mint list,
|
||||
// which the UI may render in a muted state.
|
||||
canNutzap = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
canLightning,
|
||||
canNutzap,
|
||||
sharedMints,
|
||||
nutzapMints,
|
||||
lud16,
|
||||
hasMintList
|
||||
};
|
||||
|
||||
// --- Cache store ----------------------------------------------------------
|
||||
zapCapCache.set(recipient, {
|
||||
result,
|
||||
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
|
||||
recipient: recipient.slice(0, 8) + '…',
|
||||
canLightning,
|
||||
canNutzap,
|
||||
hasMintList,
|
||||
sharedMints: sharedMints.length,
|
||||
nutzapMints: nutzapMints.length
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -294,6 +294,15 @@ let lightwalletHasWallet = false;
|
||||
let spendingHistoryLastFetchAtMs = 0;
|
||||
let spendingHistoryFetchPromise = null;
|
||||
|
||||
// Nutzap mint list (kind 10019) cache for followed users — proactively
|
||||
// fetched on startup so resolveZapCapabilities() can hit IDB instead of
|
||||
// going to relays for every followed author.
|
||||
const NUTZAP_MINTLIST_DB_NAME = 'ndk-nutzap-mintlists';
|
||||
const NUTZAP_MINTLIST_DB_VERSION = 1;
|
||||
const NUTZAP_MINTLIST_STORE = 'mintlists'; // keyPath: pubkey
|
||||
const NUTZAP_MINTLIST_PREFETCH_BATCH = 50; // max authors per fetchEvents call
|
||||
const NUTZAP_MINTLIST_PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h freshness guard
|
||||
|
||||
// Relay activity tracking
|
||||
const RELAY_EVENT_LOG_LIMIT = 200;
|
||||
const RELAY_EVENTS_DB_NAME = 'relay-events';
|
||||
@@ -600,6 +609,89 @@ async function openRelayEventsDb() {
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Nutzap mint list (kind 10019) IDB cache for followed users
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
async function openNutzapMintListDb() {
|
||||
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
|
||||
throw new Error('IndexedDB unavailable or unhealthy');
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(NUTZAP_MINTLIST_DB_NAME, NUTZAP_MINTLIST_DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
|
||||
db.createObjectStore(NUTZAP_MINTLIST_STORE, { keyPath: 'pubkey' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a parsed kind 10019 mint list for a pubkey in IDB.
|
||||
* @param {string} pubkey
|
||||
* @param {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }} entry
|
||||
*/
|
||||
async function putNutzapMintListToDb(pubkey, entry) {
|
||||
if (!pubkey) return;
|
||||
try {
|
||||
const db = await openNutzapMintListDb();
|
||||
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
|
||||
db.close();
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readwrite');
|
||||
tx.objectStore(NUTZAP_MINTLIST_STORE).put({
|
||||
pubkey,
|
||||
...entry,
|
||||
cachedAt: Date.now()
|
||||
});
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
db.close();
|
||||
} catch (error) {
|
||||
console.warn('[Worker] putNutzapMintListToDb failed', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a cached kind 10019 mint list for a pubkey from IDB.
|
||||
* Returns null when missing or stale (older than NUTZAP_MINTLIST_PREFETCH_TTL_MS).
|
||||
* @param {string} pubkey
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async function getNutzapMintListFromDb(pubkey) {
|
||||
if (!pubkey) return null;
|
||||
try {
|
||||
const db = await openNutzapMintListDb();
|
||||
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
|
||||
db.close();
|
||||
return null;
|
||||
}
|
||||
const row = await new Promise((resolve) => {
|
||||
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readonly');
|
||||
const req = tx.objectStore(NUTZAP_MINTLIST_STORE).get(pubkey);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => resolve(null);
|
||||
});
|
||||
db.close();
|
||||
if (!row) return null;
|
||||
if (Date.now() - Number(row.cachedAt || 0) > NUTZAP_MINTLIST_PREFETCH_TTL_MS) {
|
||||
return null; // stale
|
||||
}
|
||||
return row;
|
||||
} catch (error) {
|
||||
console.warn('[Worker] getNutzapMintListFromDb failed', error?.message || error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeRelayEventData(data) {
|
||||
if (data === null || data === undefined) return null;
|
||||
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return data;
|
||||
@@ -1867,6 +1959,22 @@ async function fetchStartupEventDownloads(pubkey) {
|
||||
await processIncomingNutzaps(eventsByKey.kind9321 || [], { context: 'startup-fetch' });
|
||||
|
||||
broadcastStartupStatus('Startup downloads complete', { stage: 'startup-downloads', state: 'done', counts });
|
||||
|
||||
// Tier 1: proactively prefetch kind 10019 nutzap mint lists for all
|
||||
// followed users so resolveZapCapabilities() can hit IDB. This runs in the
|
||||
// background and must not block startup completion, so we do not await it.
|
||||
try {
|
||||
const kind3Events = Array.from(eventsByKey.kind3 || []);
|
||||
const latestKind3 = kind3Events
|
||||
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0] || null;
|
||||
if (latestKind3) {
|
||||
prefetchFollowedNutzapMintLists(latestKind3).catch((error) => {
|
||||
console.warn('[Worker] background kind 10019 prefetch failed', error?.message || error);
|
||||
});
|
||||
}
|
||||
} catch (prefetchError) {
|
||||
console.warn('[Worker] kind 10019 prefetch scheduling failed', prefetchError?.message || prefetchError);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureStartupWalletSubscriptions(pubkey) {
|
||||
@@ -2843,6 +2951,15 @@ async function publishDirectProofs(debugContext = null) {
|
||||
}
|
||||
log('delete old 7375 done');
|
||||
|
||||
// Ensure a nutzap privkey exists before building the wallet payload.
|
||||
// If the user's old 17375 didn't have one (or hydration failed),
|
||||
// this auto-generates one so it's always included in the republish.
|
||||
try {
|
||||
ensureDirectNutzapP2pk();
|
||||
} catch (err) {
|
||||
log('ensureDirectNutzapP2pk failed during publish', { error: err?.message || String(err) });
|
||||
}
|
||||
|
||||
// NIP-60 standard: public "mint" tags are visible on the event;
|
||||
// the privkey lives in the encrypted content as a "privkey" tag.
|
||||
// The "pubkey" tag does NOT belong on kind 17375 (it belongs on
|
||||
@@ -3196,6 +3313,142 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind 3 (contact list) event into a de-duplicated array of followed
|
||||
* pubkeys (the 'p' tags).
|
||||
* @param {Object|null|undefined} kind3Event
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function extractFollowedPubkeysFromKind3(kind3Event) {
|
||||
if (!kind3Event || !Array.isArray(kind3Event.tags)) return [];
|
||||
const pubkeys = new Set();
|
||||
for (const tag of kind3Event.tags) {
|
||||
if (Array.isArray(tag) && tag[0] === 'p' && tag[1] && String(tag[1]).length >= 32) {
|
||||
pubkeys.add(String(tag[1]));
|
||||
}
|
||||
}
|
||||
return Array.from(pubkeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw kind 10019 event into the mint-list shape used by the cache.
|
||||
* @param {Object} event
|
||||
* @returns {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }}
|
||||
*/
|
||||
function parseKind10019Event(event) {
|
||||
const mints = Array.from(new Set((event.tags || [])
|
||||
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
|
||||
.map((tag) => normalizeMintUrl(tag[1]))
|
||||
.filter(Boolean)));
|
||||
const relays = Array.from(new Set((event.tags || [])
|
||||
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
|
||||
.map((tag) => String(tag[1]).trim())
|
||||
.filter((url) => /^wss?:\/\//i.test(url))));
|
||||
const p2pkTag = (event.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
|
||||
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
|
||||
return {
|
||||
hasMintList: mints.length > 0,
|
||||
mints,
|
||||
relays,
|
||||
p2pk,
|
||||
eventId: event.id || null,
|
||||
created_at: Number(event.created_at || 0)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively fetch kind 10019 (nutzap mint lists) for all followed pubkeys
|
||||
* discovered in the user's kind 3 contact list, and cache the results in
|
||||
* IndexedDB. This is the Tier 1 fetch strategy: followed users are pre-warmed
|
||||
* on startup so resolveZapCapabilities() can hit IDB instead of relays.
|
||||
*
|
||||
* Runs in the background and never rejects — failures are logged only.
|
||||
*
|
||||
* @param {Object|null|undefined} kind3Event - the user's kind 3 contact list
|
||||
*/
|
||||
async function prefetchFollowedNutzapMintLists(kind3Event) {
|
||||
if (!ndk) return;
|
||||
const pubkeys = extractFollowedPubkeysFromKind3(kind3Event);
|
||||
if (pubkeys.length === 0) {
|
||||
broadcastStartupStatus('No followed pubkeys to prefetch nutzap mint lists for', {
|
||||
stage: 'kind10019-prefetch',
|
||||
state: 'skipped'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
broadcastStartupStatus(`Prefetching nutzap mint lists (kind 10019) for ${pubkeys.length} followed users...`, {
|
||||
stage: 'kind10019-prefetch',
|
||||
state: 'start',
|
||||
count: pubkeys.length
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
let stored = 0;
|
||||
|
||||
try {
|
||||
// Fetch in batches to keep relay filters reasonable.
|
||||
for (let i = 0; i < pubkeys.length; i += NUTZAP_MINTLIST_PREFETCH_BATCH) {
|
||||
const batch = pubkeys.slice(i, i + NUTZAP_MINTLIST_PREFETCH_BATCH);
|
||||
try {
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [10019],
|
||||
authors: batch
|
||||
});
|
||||
const eventList = Array.from(events || []);
|
||||
|
||||
// Group events by author and keep only the latest per pubkey.
|
||||
const latestByAuthor = new Map();
|
||||
for (const event of eventList) {
|
||||
const prev = latestByAuthor.get(event.pubkey);
|
||||
if (!prev || Number(event.created_at || 0) > Number(prev.created_at || 0)) {
|
||||
latestByAuthor.set(event.pubkey, event);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [author, event] of latestByAuthor) {
|
||||
const parsed = parseKind10019Event(event);
|
||||
await putNutzapMintListToDb(author, parsed);
|
||||
stored++;
|
||||
}
|
||||
|
||||
// Record an explicit "no mint list" entry for followed pubkeys
|
||||
// that returned nothing, so we can short-circuit future lookups
|
||||
// within the TTL window.
|
||||
for (const author of batch) {
|
||||
if (!latestByAuthor.has(author)) {
|
||||
await putNutzapMintListToDb(author, {
|
||||
hasMintList: false,
|
||||
mints: [],
|
||||
relays: [],
|
||||
p2pk: null,
|
||||
eventId: null,
|
||||
created_at: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (batchError) {
|
||||
console.warn('[Worker] kind 10019 prefetch batch failed', batchError?.message || batchError);
|
||||
}
|
||||
}
|
||||
|
||||
broadcastStartupStatus(`Nutzap mint list prefetch done (${stored} stored)`, {
|
||||
stage: 'kind10019-prefetch',
|
||||
state: 'done',
|
||||
count: stored,
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
} catch (error) {
|
||||
broadcastStartupStatus('Nutzap mint list prefetch failed', {
|
||||
stage: 'kind10019-prefetch',
|
||||
state: 'failed',
|
||||
error: error?.message || String(error),
|
||||
durationMs: Date.now() - startedAt
|
||||
});
|
||||
console.warn('[Worker] prefetchFollowedNutzapMintLists failed', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWalletFetchMintList(requestId, targetPubkey, port) {
|
||||
try {
|
||||
if (!ndk) throw new Error('NDK not initialized');
|
||||
@@ -3205,6 +3458,30 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
|
||||
throw new Error('Target pubkey is required');
|
||||
}
|
||||
|
||||
// Tier 2 lazy fetch: consult the IDB cache (populated by the Tier 1
|
||||
// startup prefetch) before hitting relays. A fresh cache hit avoids a
|
||||
// round-trip for non-followed-but-recently-seen pubkeys too.
|
||||
try {
|
||||
const cached = await getNutzapMintListFromDb(author);
|
||||
if (cached) {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: {
|
||||
hasMintList: Boolean(cached.hasMintList),
|
||||
mints: Array.isArray(cached.mints) ? cached.mints : [],
|
||||
relays: Array.isArray(cached.relays) ? cached.relays : [],
|
||||
p2pk: cached.p2pk || null,
|
||||
eventId: cached.eventId || null,
|
||||
created_at: Number(cached.created_at || 0)
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (_cacheError) {
|
||||
// Fall through to relay fetch on any cache error.
|
||||
}
|
||||
|
||||
const events = await ndk.fetchEvents({
|
||||
kinds: [10019],
|
||||
authors: [author],
|
||||
@@ -3215,43 +3492,35 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
|
||||
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0];
|
||||
|
||||
if (!latest) {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: {
|
||||
hasMintList: false,
|
||||
mints: [],
|
||||
relays: [],
|
||||
p2pk: null,
|
||||
eventId: null
|
||||
}
|
||||
});
|
||||
const emptyEntry = {
|
||||
hasMintList: false,
|
||||
mints: [],
|
||||
relays: [],
|
||||
p2pk: null,
|
||||
eventId: null,
|
||||
created_at: 0
|
||||
};
|
||||
// Cache the negative result so repeated lookups are cheap.
|
||||
putNutzapMintListToDb(author, emptyEntry).catch(() => {});
|
||||
port.postMessage({ type: 'response', requestId, data: emptyEntry });
|
||||
return;
|
||||
}
|
||||
|
||||
const mints = Array.from(new Set((latest.tags || [])
|
||||
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
|
||||
.map((tag) => normalizeMintUrl(tag[1]))
|
||||
.filter(Boolean)));
|
||||
const parsed = parseKind10019Event(latest);
|
||||
|
||||
const relays = Array.from(new Set((latest.tags || [])
|
||||
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
|
||||
.map((tag) => String(tag[1]).trim())
|
||||
.filter((url) => /^wss?:\/\//i.test(url))));
|
||||
|
||||
const p2pkTag = (latest.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
|
||||
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
|
||||
// Cache the relay result for future lookups.
|
||||
putNutzapMintListToDb(author, parsed).catch(() => {});
|
||||
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: {
|
||||
hasMintList: mints.length > 0,
|
||||
mints,
|
||||
relays,
|
||||
p2pk,
|
||||
eventId: latest.id || null,
|
||||
created_at: Number(latest.created_at || 0)
|
||||
hasMintList: parsed.hasMintList,
|
||||
mints: parsed.mints,
|
||||
relays: parsed.relays,
|
||||
p2pk: parsed.p2pk,
|
||||
eventId: parsed.eventId,
|
||||
created_at: parsed.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -4304,6 +4573,97 @@ async function handleWalletCheckProofs(requestId, port) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight republish of ONLY the kind 17375 wallet event.
|
||||
*
|
||||
* Kind 17375 is a replaceable event (NIP-60, d-tag = ""), so publishing a
|
||||
* new one automatically supersedes the old one on relays — no NIP-09
|
||||
* deletions needed. This skips the heavy publishDirectProofs() which
|
||||
* rewrites all 7375 token events too (100+ signing round-trips).
|
||||
*
|
||||
* Just: ensure privkey → build tag-array payload → encrypt → sign → publish.
|
||||
*/
|
||||
async function handleWalletRepublish(requestId, port) {
|
||||
try {
|
||||
if (!ndk || !currentPubkey) {
|
||||
port.postMessage({ type: 'response', requestId, error: 'NDK not ready' });
|
||||
return;
|
||||
}
|
||||
await ensureDirectWalletLoaded();
|
||||
|
||||
// Ensure a nutzap privkey exists (auto-generate if missing)
|
||||
try {
|
||||
ensureDirectNutzapP2pk();
|
||||
} catch (err) {
|
||||
console.warn('[Worker] handleWalletRepublish: ensureDirectNutzapP2pk failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const NDKEvent = getNDKEventCtor();
|
||||
if (!NDKEvent) {
|
||||
port.postMessage({ type: 'response', requestId, error: 'NDKEvent constructor not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build NIP-60 tag-array payload: [["mint",url],["privkey",hex]]
|
||||
const mintTags = getDirectWalletMints().map((mintUrl) => ['mint', mintUrl]);
|
||||
const walletPayloadObj = [
|
||||
...getDirectWalletMints().map((url) => ['mint', url]),
|
||||
...(directNutzapPrivateKeyHex ? [['privkey', directNutzapPrivateKeyHex]] : [])
|
||||
];
|
||||
const walletPayload = JSON.stringify(walletPayloadObj);
|
||||
|
||||
// NIP-44 encrypt the payload
|
||||
const encryptedWalletResp = await messageSigner.requestFromPage('nip44Encrypt', {
|
||||
recipientPubkey: currentPubkey,
|
||||
plaintext: walletPayload
|
||||
});
|
||||
const encryptedWallet = typeof encryptedWalletResp === 'string' ? encryptedWalletResp : encryptedWalletResp?.ciphertext;
|
||||
|
||||
if (!encryptedWallet) {
|
||||
port.postMessage({ type: 'response', requestId, error: 'Failed to encrypt wallet payload' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build and publish the new 17375 event (replaceable — supersedes old one)
|
||||
const walletEvent = new NDKEvent(ndk, {
|
||||
kind: 17375,
|
||||
tags: mintTags,
|
||||
content: encryptedWallet,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
await signEventWithMessageSigner(walletEvent);
|
||||
const relaySet = await walletEvent.publish();
|
||||
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
trackRelayWrite(relay.url);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Worker] handleWalletRepublish: 17375 published', {
|
||||
relayCount: relaySet ? relaySet.size : 0,
|
||||
mintCount: mintTags.length,
|
||||
hasPrivkey: Boolean(directNutzapPrivateKeyHex)
|
||||
});
|
||||
|
||||
const payload = getWalletBalancePayload();
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: {
|
||||
success: true,
|
||||
mints: getDirectWalletMints(),
|
||||
relayCount: relaySet ? relaySet.size : 0,
|
||||
...payload
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Worker] handleWalletRepublish failed:', error?.message || error);
|
||||
port.postMessage({ type: 'response', requestId, error: error.message || 'Republish failed' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleWalletShutdown(requestId, port) {
|
||||
try {
|
||||
clearWalletListeners();
|
||||
@@ -6500,7 +6860,11 @@ self.onconnect = (event) => {
|
||||
case 'walletCheckProofs':
|
||||
await handleWalletCheckProofs(requestId, port);
|
||||
break;
|
||||
|
||||
|
||||
case 'walletRepublish':
|
||||
await handleWalletRepublish(requestId, port);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('[Worker] Unknown message type:', type);
|
||||
}
|
||||
|
||||
1270
www/post-feed.html
Normal file
1270
www/post-feed.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user