Compare commits

..

7 Commits

Author SHA1 Message Date
Laan Tungir
6b45092bc8 Fix post-feed.html: recursive reply fetching for nested replies
Replies to replies don't carry the root post ID in their e tags, only their
immediate parent's ID. The old single-level #e filter missed nested replies.

Added fetchRepliesRecursive() which does breadth-first recursive fetching:
- Starts with root post ID, fetches direct replies
- For each reply found, fetches replies to THAT reply
- Continues up to 5 levels deep (REPLY_FETCH_MAX_DEPTH)
- Deduplicates by event ID
- Per-level timeout (8s) to guard against slow relays
- Real-time handler also triggers 1-level recursive fetch for new replies

This matches Amethyst's ThreadFilterAssemblerSubscription approach.
2026-06-25 20:20:10 -04:00
Laan Tungir
6d39188967 Fix post-feed.html: remove collapse, fix missing reply, move composer to top
1. Removed collapse/expand functionality — all replies shown, no hidden counts
2. Fixed missing last reply — added safety check to ensure all fetched replies are rendered
3. Moved reply composer to top (under main post, before replies list)
2026-06-25 20:00:43 -04:00
Laan Tungir
c5f682c5fe Feed upgrade Phase 2.5: Threaded replies with vertical lines in post-feed.html
- Added computeReplyLevels() to compute reply depth from NIP-10 e tags
- Added buildDepthFirstOrder() for depth-first thread sorting (root first, children grouped by parent chronologically)
- Render vertical indent lines using CSS variables (var(--muted-color) for non-selected, var(--accent-color) for focused post)
- Added collapse/expand functionality with hidden reply count
- Added focused post highlight via ?focus=<eventId> URL param
- Real-time subscriptions preserved — new replies appear in correct threaded position
- All colors use CSS variables — no hardcoded colors
2026-06-25 19:53:36 -04:00
Laan Tungir
073fb9e9af Feed upgrade Phase 1 & 2: simplified feed2.html + post-feed.html thread page
Phase 1 (feed2.html):
- Removed inline comment rendering and Comments toggle button
- Comment button now opens post-feed.html?event=<eventId> in new tab
- Removed inline reply composer (kept quote composer)
- Fixed missing queryCache dependency in initPostCards
- Fixed hardcoded color:red -> var(--primary-color)

Phase 2 (post-feed.html):
- New post thread page showing post + all replies + inline composer
- Parses ?event=<hex|nevent|note> URL parameter
- Fetches replies via { kinds: [1], '#e': [postId] }
- Real-time subscriptions for new replies
- Comment button on replies opens sub-threads in new tabs
- Error states for missing/invalid/not-found posts
- All CSS uses variables, no hardcoded colors
2026-06-25 19:45:36 -04:00
Laan Tungir
2f45c78741 Lightweight 17375-only republish: skip 7375 rewrite, no deletions needed
Kind 17375 is a replaceable event (NIP-60, d-tag=''), so publishing a new
one automatically supersedes the old one on relays. The republish now only:
1. ensureDirectNutzapP2pk() - generate privkey if missing
2. Build NIP-60 tag-array payload
3. NIP-44 encrypt + sign + publish single 17375 event
That's 1 signing round-trip instead of 100+.
Reduced timeout from 120s back to 30s.
Bumped WORKER_REVISION to nip60-lightweight-republish-1.
2026-06-25 14:05:33 -04:00
Laan Tungir
6d41680297 Increase walletRepublish timeout to 120s for large wallets
The 45s timeout was too short for wallets with many old 7375 token events —
each deletion requires a signing round-trip through the nos2x extension.
2026-06-25 14:01:01 -04:00
Laan Tungir
b2a50376df Ensure nutzap privkey is always included in kind 17375 republish
- Added ensureDirectNutzapP2pk() call in publishDirectProofs before building wallet payload
- This auto-generates a privkey if one wasn't hydrated from the old 17375 event
- Bumped WORKER_REVISION to nip60-privkey-ensure-1
2026-06-25 13:48:34 -04:00
7 changed files with 2558 additions and 370 deletions

368
plans/feed-upgrade.md Normal file
View File

@@ -0,0 +1,368 @@
# 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
- [ ] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
- [ ] **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')`.
- [ ] **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)
- [ ] **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
- [ ] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
- [ ] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
- [ ] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
- [ ] **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] }`.
- [ ] **2.5** Render the comment thread below the original post:
- Flat thread (all replies in chronological order) — simplest, most reliable
- Each reply rendered using `renderPostItem` from `post-interactions.mjs`
- Start with flat; can add nesting/threading later
- [ ] **2.6** Add an inline composer at the bottom for posting replies. The composer should:
- Tag the original post: `['e', postId, '', 'reply']`
- Tag the original author: `['p', postPubkey]`
- Use the existing post composer component (`post-composer.mjs`)
- [ ] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive.
- [ ] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions.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
- [ ] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html` (or a shared module):
- 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>`
- [ ] **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
- [ ] **2.5.3** Render each reply with vertical indent lines:
- Wrap each reply in a container div with `padding-left: calc(level * 12px)` (or similar)
- For each level 0..N-1, add a vertical line using `border-left: 2px solid var(--muted-color)` on a positioned div
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
- Use CSS pseudo-elements or nested divs for the lines
- [ ] **2.5.4** Add collapse/expand functionality:
- Each reply has a collapse toggle (small button or click on the vertical line)
- Collapsing a reply hides all of its descendants
- Show a count of hidden replies when collapsed (e.g., "3 replies hidden")
- Collapsed state stored in a Set in memory
- [ ] **2.5.5** Highlight the focused post:
- If the URL has a `#<eventId>` hash or a `focus=<eventId>` param, scroll to and highlight that post
- The highlighted post's vertical line uses `var(--accent-color)`
### 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.

View File

@@ -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.19v0.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 27582769, 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 36).
- [ ] **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.

714
www/feed2.html Normal file
View File

@@ -0,0 +1,714 @@
<!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"></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 }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
}
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,
queryCache,
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);
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:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
}
})();
</script>
</body>
</html>

View File

@@ -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-republish-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)
@@ -1426,7 +1426,7 @@ export function walletShutdown() {
}
export function walletRepublish() {
return sendWorkerRequest('walletRepublish', {}, 45000, 'walletRepublish timeout');
return sendWorkerRequest('walletRepublish', {}, 30000, 'walletRepublish timeout');
}
export function walletPublishMintList(relays = [], receiveMints = []) {

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.21",
"VERSION_NUMBER": "0.7.21",
"BUILD_DATE": "2026-06-25T17:39:32.779Z"
"VERSION": "v0.7.28",
"VERSION_NUMBER": "0.7.28",
"BUILD_DATE": "2026-06-26T00:20:10.087Z"
}

View File

@@ -2843,6 +2843,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
@@ -4304,6 +4313,16 @@ 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) {
@@ -4311,7 +4330,63 @@ async function handleWalletRepublish(requestId, port) {
return;
}
await ensureDirectWalletLoaded();
await publishDirectProofs({ traceId: 'walletRepublish' });
// 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',
@@ -4319,6 +4394,7 @@ async function handleWalletRepublish(requestId, port) {
data: {
success: true,
mints: getDirectWalletMints(),
relayCount: relaySet ? relaySet.size : 0,
...payload
}
});

1258
www/post-feed.html Normal file

File diff suppressed because it is too large Load Diff