Phase 3 — Zap Capability Indicators: - Added resolveZapCapabilities() to zaps.mjs with 5-min TTL cache - Checks lud16 (from profile cache) for Lightning capability - Checks kind 10019 (via walletFetchMintList) for nutzap capability - Computes shared mints between sender and recipient - Added capability badges (🥜/⚡) to zap button in post-interactions.mjs - Badges use var(--accent-color) for available, var(--muted-color) for unavailable - Added proactive kind 10019 batch fetch for followed users in ndk-worker.js - New IDB store ndk-nutzap-mintlists for persistent caching (6h TTL) - Non-blocking: badges appear after async resolution Phase 4 — Zap Rail Selector: - Updated promptZapDetails with rail toggle (🥜 Nutzap / ⚡ Lightning) - Shows Cashu balance and insufficient balance warning - Shows shared mint info when nutzap selected - Default rail: nutzap for <1000 sats, Lightning for >=1000 sats - Updated handleZapClick to resolve capabilities and respect user's rail choice - Added zap rail toggle CSS to client.css (all CSS variables) - Wired walletGetBalance through post-interactions2.mjs and post-feed.html Phase 5 — Balance Check and Error Handling: - Pre-flight balance check before sending (hard stop if insufficient) - Barely-sufficient balance confirmation dialog - Friendly error messages for melt/swap/timeout/insufficient failures - Post-zap balance refresh via ndkWalletBalance event Review fix: threaded walletGetMints through post-interactions2.mjs and post-feed.html so resolveZapCapabilities can compute shared mints correctly
18 KiB
Feed Upgrade — Implementation Plan
Overview
A series of improvements to the feed page and post interaction bar, centered on:
- Simplified feed — show posts without inline replies, just counts
- Post thread page — click comment button to open post with full comment thread in a new tab
- Zap capability indicators — show what the recipient can receive (nutzap, Lightning)
- Zap rail selector — let users choose between nutzap and Lightning melt
- 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— shows posts with inline replies (toggled by "Comments" button). Replies appear under the post as they occur. This is unreliable.www/feed2.html— copy of feed.html, to be modified per this planwww/post.html— posting/composer page (NOT a thread viewer). Accepts?event=,?npub=,?profile=params.www/note.html— long-form note editor (kind 30023/30024), NOT for kind 1 threadswww/js/post-interactions.mjs— renders the interaction bar (like, comment, quote, zap buttons) and handles zap routingwww/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
onCommentIntentFnto show inline comments, openpost-feed.html?event=<eventId>in a new tab usingwindow.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 |
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.htmlbased on thewww/template.htmlpattern — standard header, hamburger, sidenav, footer. -
2.2 Parse the
eventURL parameter usingURLSearchParams(same pattern aspost.htmlline 248). Decode nevent/note to hex if needed. -
2.3 Fetch the original post event via the worker's
ndkFetchEventsorqueryCachefunction. Display it usingrenderPostItemfrompost-interactions.mjs. -
2.4 Fetch all replies — kind 1 events with an
etag pointing to the post ID. UsendkFetchEventswith a filter like{ kinds: [1], '#e': [postId] }. Updated: Now uses recursive fetching (fetchRepliesRecursive) to find nested replies up to 5 levels deep. -
2.5 Render the comment thread below the original post:
- Threaded with vertical indent lines (Phase 2.5)
- Each reply rendered using
renderPostItemfrompost-interactions2.mjs - Depth-first ordering with level computation from NIP-10 e tags
-
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 (.topPostComposerInputCSS)
- Tags the original post:
-
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.
-
2.8 Each reply should have its own interaction bar (like, zap, quote) via
post-interactions2.mjs. The comment button on a reply openspost-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:
-
ThreadLevelCalculator.replyLevel(note, cachedLevels)— computes the depth of each reply recursively:- Root post (no
etag reply) → level 0 - A reply's level =
max(parent's level for each parent) + 1 - Parents are determined by NIP-10
etags in the event
- Root post (no
-
Modifier.drawReplyLevel(level, color, selected)— draws vertical lines to the left of each post:- Draws
levelvertical 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
- Draws
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 levelsvar(--accent-color)for the current post's level (the post being viewed)
Tasks
-
2.5.1 Add a
computeReplyLevels(events, rootEventId)function topost-feed.html:- Build a map of
eventId → parentEventIdfrom theetags (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>
- Build a map of
-
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) - 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, otherwisevar(--muted-color)
- Wrap each reply in a container div with
-
2.5.4 Add collapse/expand functionality— Removed per user request. All replies are shown, no collapse. -
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 getsvar(--accent-color)outline
- If the URL has a
CSS approach (CSS variables only)
.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 |
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
walletFetchMintListworker 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.mjswhen 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 viawalletGetMints
Tasks
-
3.1 Create a
resolveZapCapabilities(pubkey)function inwww/js/zaps.mjsthat checks:- Does the recipient have a lud16 (Lightning address)? → check
profile-cache.mjscache (already fetched for post rendering) →canLightning: true - Does the recipient have a kind 10019 (nutzap mint list)? → check local cache first, then fetch via
walletFetchMintListif 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 }
- Does the recipient have a lud16 (Lightning address)? → check
-
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
- Cache results in an in-memory Map with TTL (5 minutes) in
-
3.4 Update the zap button rendering in
post-interactions.mjsto show capability indicators:- Add a small badge element next to the zap button
- Show 🥜 in
var(--accent-color)ifcanNutzapis true - Show 🥜 in
var(--muted-color)if recipient has 10019 but no shared mint - Show ⚡ in
var(--accent-color)ifcanLightningis 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 |
Add resolveZapCapabilities() with caching, lazy fetch for non-followed users |
www/js/post-interactions.mjs |
Add capability badges to zap button rendering |
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.
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
promptZapDetailsinwww/js/zaps.mjsto 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 usesvar(--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
handleZapClickinpost-interactions.mjsso 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 |
Update promptZapDetails with rail selector UI |
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?"
- If balance < zap amount → show "Insufficient balance. Current: X sats, needed: Y sats" in
-
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 |
Add balance check before zap, improve error handling |
Implementation Order
- Phase 1 (simplified feed) — remove inline replies from feed2.html, comment button opens post-feed.html in new tab
- Phase 2 (post thread page) — create post-feed.html to show full post + comment thread
- Phase 3 (zap capability indicators) — show what the recipient can receive
- Phase 4 (rail selector) — let users choose nutzap vs Lightning melt
- Phase 5 (balance + errors) — pre-flight checks and better error messages
Phases 1-2 are the feed restructuring. Phases 3-5 are zap improvements.