Files
client/plans/feed-upgrade.md
Laan Tungir a42edcf6d6 Feed upgrade Phases 3-5: zap capability indicators, rail selector, balance checks
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
2026-06-25 20:48:38 -04:00

18 KiB

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 — 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 plan
  • www/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 threads
  • www/js/post-interactions.mjs — renders the interaction bar (like, comment, quote, zap buttons) and handles zap routing
  • 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 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 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] }. 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 renderPostItem from post-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 (.topPostComposerInput CSS)
  • 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 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:

    • 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)
    • 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)
  • 2.5.4 Add collapse/expand functionalityRemoved 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 gets var(--accent-color) outline

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 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 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 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 promptZapDetails in 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 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?"
  • 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

  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.