Compare commits

..

21 Commits

Author SHA1 Message Date
Laan Tungir
9d9d0f88f5 Remove discovered relays auto-refresh interval — fetch only on manual expand click to prevent worker message queue starvation 2026-06-26 10:08:13 -04:00
Laan Tungir
b11161862a Fix relays.html relay data timeout: separate discovered relays refresh to 15s interval only when expanded, make worker getDiscoveredRelays handler non-blocking (fire-and-forget) to prevent message queue starvation 2026-06-26 09:54:18 -04:00
Laan Tungir
fe35b2dfa1 Phase 5: Add discovered relays (outbox) table to relays.html — collapsible section showing temporary outbox relays with connection status, serving follow count, and sample npubs 2026-06-26 09:46:35 -04:00
Laan Tungir
ae67e65969 Mark Phases 1-4 as complete in feed-outbox-assessment.md, update summary table with status column 2026-06-26 09:44:56 -04:00
Laan Tungir
19344942b0 Fix relays.html sidenav connection history toggle: remove section title and horizontal rules, use SVG checkbox style matching the relay table checkboxes 2026-06-26 09:43:02 -04:00
Laan Tungir
c758333269 Implement feed outbox model pre-warming (Phases 1-3) and relays.html connection history toggle with performance fixes (Phase 4)
Phase 1 (ndk-worker.js): Add warmOutbox, setRelayEventLogging, and getDiscoveredRelays RPC handlers. Gate broadcastRelayEvent and logRelayEvent IndexedDB writes behind relayEventLoggingEnabled flag (default off). trackRelayRead/trackRelayWrite left ungated to preserve footer activity carrots.

Phase 2 (init-ndk.mjs): Add warmOutbox, setRelayEventLogging, and getDiscoveredRelays page API exports with proper request/response wiring and timeouts.

Phase 3 (feed.html): Pre-warm outbox tracker via warmOutbox() before bootstrapFeedPosts and fetchFeedWindow calls so NDK routes author-based subscriptions to follows' write relays before short-lived fetchEvents EOSE.

Phase 4 (relays.html): Add 'Show connection history' sidenav toggle (sidenavRowToggle pattern from feed.html), gate ndkRelayEvent listener and DB loading behind connectionHistoryEnabled flag, switch live event stream to incremental DOM appending, sync state on init/beforeunload.
2026-06-26 09:38:31 -04:00
Laan Tungir
3ed0c35611 Fix post-feed reply composer width: #divComposer was nested in #divThread and re-applied 80% (560px); now fills parent (700px) 2026-06-26 07:26:09 -04:00
Laan Tungir
d9503c1532 Revert: remove layout:'inline' from mountComposer in post-feed.html — made it worse 2026-06-26 07:18:32 -04:00
Laan Tungir
fa0859dcdd Fix post-feed.html: reply composer width — add layout:'inline' to mountComposer
The composer wrapper defaults to width:60% (post-composer.css). Adding
layout:'inline' applies the --inline class which sets width:100%.
2026-06-25 21:29:31 -04:00
Laan Tungir
0c340316ae Fix post-feed.html: reply composer width — add width:100% and box-sizing:border-box to .topPostComposerInput 2026-06-25 21:22:38 -04:00
Laan Tungir
313a55992f Change Nutzap button label from peanut emoji to text 'Nutzap' 2026-06-25 21:18:47 -04:00
Laan Tungir
ece3a136c1 Redesign: Split Zap into two buttons (Zap + Nutzap) with separate counts and popups
- Split single Zap button into Zap ( Lightning) and Nutzap (🥜 Cashu) buttons
- Each button has its own count display and popup dialog
- Zap button: only sends Lightning zaps via Cashu melt, simplified dialog (no rail selector)
- Nutzap button: sends NIP-61 nutzaps, dialog shows recipient's accepted mints
- Removed capability badges (buttons themselves show capability via dimming)
- Removed separate nutzap count display (count now on Nutzap button)
- Added applyZapCapabilityDimming() — dims buttons based on resolveZapCapabilities
- Added promptNutzapDetails() to zaps.mjs — shows recipient mints and shared mint info
- Simplified promptZapDetails() — removed rail selector, Lightning only
- Added .interaction-item.nutzap and .dimmed CSS styles
- All colors use CSS variables only
2026-06-25 21:13:55 -04:00
Laan Tungir
543ae6a1b3 Fix feed.html: disable inline comment rendering that caused flash of replies
Two fixes:
1. post-interactions2.mjs: only call addCommentToPost when getCommentsVisible() is true
2. feed.html: call cards.setCommentsVisible(false) after initPostCards to disable
   inline comment rendering for the new simplified feed

The old feed (feed-old.html) still has the Comments toggle and can re-enable them.
2026-06-25 21:02:21 -04:00
Laan Tungir
508952a1ea Rename feed.html -> feed-old.html, feed2.html -> feed.html
The new simplified feed (formerly feed2.html) is now the primary feed page.
Updated post-feed.html back button to point to feed.html instead of feed2.html.
2026-06-25 20:57:34 -04:00
Laan Tungir
15ef106eb5 Fix feed2.html: add missing walletGetBalance and walletGetMints imports
feed2.html was missing walletGetBalance and walletGetMints in both the
init-ndk.mjs import and the initPostCards call. Without these, the zap
rail selector and capability badges couldn't function — handleZapClick
couldn't resolve zap capabilities or show the rail toggle.
2026-06-25 20:55:02 -04:00
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
Laan Tungir
0267233f87 Fix post-feed.html reply composer formatting — add missing .topPostComposerInput CSS rule
The reply composer was missing the CSS rule that gives it visible formatting
(border, padding, min-height, border-radius). Copied the rule from post.html.
2026-06-25 20:29:51 -04:00
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
14 changed files with 5404 additions and 516 deletions

View File

@@ -0,0 +1,934 @@
# Feed Outbox Model — Assessment & Plan
## Your suspicion is correct, and NDK already has the machinery built in.
You are missing posts from some follows because the feed only queries the
**relays you subscribe to** (your read/both relays from your kind 10002 list).
If a follow posts exclusively to relays you are *not* connected to, those posts
never arrive. This is exactly the problem the Nostr **outbox model** (NIP-65 +
NIP-17 relay lists) solves: instead of only listening on your relays, you look
up each follow's kind 10002 relay list and fetch their posts from *their* write
relays.
The good news: **NDK's outbox model is already enabled** in your bundle and is
already wired into the subscription path. The bad news: it is being defeated by
a race condition in how [`www/feed.html`](www/feed.html) bootstraps the feed.
---
## How NDK's outbox model works (already in your bundle)
Relevant code in [`www/ndk-core.bundle.js`](www/ndk-core.bundle.js):
1. **Outbox is on by default** — [`NDK`](www/ndk-core.bundle.js:43195) constructor
at line 43295: `if (!(opts.enableOutboxModel === false))` creates an
`outboxPool` (default relays `wss://purplepag.es/`, `wss://nos.lol/`) and an
`outboxTracker`. Your worker never sets `enableOutboxModel: false`, so it is
active. Confirmed: [`www/ndk-worker.js`](www/ndk-worker.js:1506) `ndkOptions`
does not disable it.
2. **Author-based subscriptions trigger outbox tracking**
[`NDK.subscribe`](www/ndk-core.bundle.js:43665): when a filter has `authors`,
it calls `this.outboxTracker?.trackUsers(authors)`. This fetches each
author's kind 10002 (and kind 3 fallback) relay list from the outbox pool.
3. **Relay sets are computed per-author**
[`calculateRelaySetsFromFilter`](www/ndk-core.bundle.js:33864) calls
[`getRelaysForFilterWithAuthors`](www/ndk-core.bundle.js:31650) →
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31594) which
picks ~2 write relays per author and splits the `authors` array across the
relays each author actually writes to.
4. **Late outbox data refreshes subscriptions** — when
[`OutboxTracker`](www/ndk-core.bundle.js:42790) resolves a user's relay list,
it emits `user:relay-list-updated`, and the NDK constructor (line 43301)
calls `subscription.refreshRelayConnections()` to add newly-discovered
relays to the live subscription.
5. **Fallback for unknown authors**
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31639): authors
with no known relay list are sent to `pool.permanentAndConnectedRelays()`
(your read relays). So outbox never *loses* events vs. the old behavior; it
only *adds* coverage.
So in principle, a plain `ndk.subscribe({ kinds:[1], authors:[...] })` already
does outbox routing. The feature you want exists.
---
## Why your feed still misses people — the root cause
The problem is a **race / staleness condition** in
[`www/feed.html`](www/feed.html), not a missing NDK feature.
### The bootstrap path (lines 481545)
[`fetchFeedWindow`](www/feed.html:481) does this for each time window:
1. `queryCache(filters)` — reads the Dexie cache synchronously.
2. `ndkFetchEvents(filters)` — fires a relay subscription with
`closeOnEose: true` (via [`handleNdkFetchEvents`](www/ndk-worker.js:6316)).
The filters are `{ kinds:[1], authors, since, limit }`. This *does* go through
NDK's outbox routing. But there are two issues:
#### Issue A — Outbox data is not pre-warmed
`outboxTracker.trackUsers` is called inside `NDK.subscribe`, but it is
**fire-and-forget** relative to `EOSE`. The subscription starts immediately
against whatever relays are currently known (initially: your read relays only,
since the tracker is empty). When the outbox data arrives moments later,
`refreshRelayConnections` adds the new relays — but only for **long-lived**
subscriptions. For a `closeOnEose: true` `fetchEvents` call, the subscription
may `EOSE` and close on your read relays *before* the outbox tracker finishes
resolving the follow's kind 10002 lists. Result: you get posts only from
follows who happen to post to your read relays.
This is the core bug. The bootstrap windows in
[`FEED_BOOTSTRAP_WINDOWS_SECONDS`](www/feed.html:179) fire a burst of
short-lived `fetchEvents` calls that race the outbox tracker.
#### Issue B — The live subscription is fine, but late
[`ensureLiveFeedSubscription`](www/feed.html:547) creates a long-lived
`subscribe({ kinds:[1], authors, since })` with `closeOnEose:false`. This one
*does* benefit from `refreshRelayConnections` — once the outbox tracker
resolves, new relays get added and late posts stream in. But:
- It only covers events **after** `since` (newest known 30s, or now 1h).
Historical posts from follows on foreign relays that arrived *before* the
outbox data resolved are never backfilled.
- The bootstrap is what fills the initial feed view, and that's where Issue A
bites.
#### Issue C — Outbox tracker entries expire in 2 minutes
[`OutboxTracker`](www/ndk-core.bundle.js:42800): `entryExpirationTimeInMS: 2 * 60 * 1000`.
The tracker is an LRU with a 2-minute TTL. If the live subscription was created
and the tracker entries expired, a later `refreshRelayConnections` won't fire
because there's no `user:relay-list-updated` event to re-trigger it. The
subscription keeps running on whatever relays it had. This is mostly fine for
the live sub, but means any *new* `fetchEvents` call (e.g. "See More" or a new
follow added via [`ingestContactList`](www/feed.html:567)) starts cold again.
---
## What does NOT need to change
- **Do not** disable the outbox model or build a custom relay-fetching layer.
NDK already does this; you'd be reimplementing
[`getRelayListForUsers`](www/ndk-core.bundle.js:42662) and
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31594).
- **Do not** manually connect to every follow's write relays in the main pool.
NDK's `pool.getRelay(url, true, true)` (temporary relay) already handles
per-subscription temporary connections via
[`calculateRelaySetsFromFilter`](www/ndk-core.bundle.js:33864). Adding them
permanently would bloat the main pool.
## What DOES need to change
The fix is to **pre-warm the outbox tracker before issuing the short-lived
bootstrap fetches**, so that by the time `fetchEvents` runs, NDK already knows
each follow's write relays and routes the subscription correctly.
NDK exposes this via `ndk.outboxTracker.trackUsers(pubkeys)`. Your worker does
not currently call it directly. The cleanest fix is a new worker RPC,
`warmOutbox(pubkeys)`, that calls `ndk.outboxTracker.trackUsers(pubkeys)` and
awaits it, plus a feed.html change to call it before
[`bootstrapFeedPosts`](www/feed.html:507).
---
## Unified Implementation Plan
The work is organized into phases that build on each other. Each phase is
self-contained and deliverable independently. The phases merge the feed
outbox fix, the relays.html performance fix, the outbox visualization, and
the Amethyst-parity relay management features into one coherent sequence.
### Phase 1 — Worker: expose outbox pre-warming + relay-event logging toggle
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
- [x] **1.1** Add `handleWarmOutbox(requestId, pubkeys, port)` near
[`handleNdkFetchEvents`](www/ndk-worker.js:6316). It should:
- Guard `if (!ndk?.outboxTracker) { respond empty ok }`.
- `await ndk.outboxTracker.trackUsers(pubkeys)`.
- Respond `{ type: 'warmOutboxResult', requestId, ok: true, count: pubkeys.length }`.
- Wrap in try/catch; never reject (pre-warming is best-effort).
- [x] **1.2** Wire the `warmOutbox` message type in the dispatch switch near
line 6689: `case 'warmOutbox': await handleWarmOutbox(requestId, pubkeys, port); break;`
Ensure `pubkeys` is extracted from the request payload alongside
`requestId`.
- [x] **1.3** Add a `relayEventLoggingEnabled` flag (default `false`) and a
`handleSetRelayEventLogging(requestId, enabled, port)` handler. When
`false`, [`broadcastRelayEvent`](www/ndk-worker.js:917) skips the
`broadcast()` call AND [`logRelayEvent`](www/ndk-worker.js:899) skips
`writeRelayEventToDb`. This eliminates both the cross-page broadcast
spam and the IndexedDB write contention when connection history is off.
- [x] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
switch.
- [x] **1.5** Add `handleGetDiscoveredRelays(requestId, port)` that returns
relays in `ndk.pool.relays` that are *not* in `relayTypes` (your kind
10002 list). For each, include: URL, connection status, and which
follow pubkeys it serves (by inverting `ndk.outboxTracker.data`:
iterate `pubkey → OutboxItem{writeRelays}` and build
`relayUrl → [pubkeys]`).
- [x] **1.6** Wire the `getDiscoveredRelays` message type in the dispatch
switch.
### Phase 2 — Page API: add `warmOutbox`, `setRelayEventLogging`, `getDiscoveredRelays` exports
**File:** [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
- [x] **2.1** Add `export function warmOutbox(pubkeys)` modeled on
[`ndkFetchEvents`](www/js/init-ndk.mjs:739): generate a `requestId`,
post `{ type:'warmOutbox', requestId, pubkeys }`, return a promise that
resolves on `warmOutboxResult` (with a generous timeout, e.g. 15s, that
resolves rather than rejects — pre-warm is best-effort).
- [x] **2.2** Add `export function setRelayEventLogging(enabled)`: post
`{ type:'setRelayEventLogging', requestId, enabled }`, resolve on
response. Fire-and-forget is fine (no need to await).
- [x] **2.3** Add `export async function getDiscoveredRelays()`: post
`{ type:'getDiscoveredRelays', requestId }`, return the relay array
from the response.
### Phase 3 — Feed: pre-warm outbox before bootstrap
**File:** [`www/feed.html`](www/feed.html)
- [x] **3.1** Import `warmOutbox` in the existing import block (line 143).
- [x] **3.2** In [`ingestContactList`](www/feed.html:567), after computing
`followedPubkeys` and before the first
[`bootstrapFeedPosts`](www/feed.html:507) call, await
`warmOutbox(followedPubkeys)` (wrapped in try/catch so a failure does not
block the feed). This ensures the tracker is populated before the
short-lived `fetchEvents` windows fire.
- [x] **3.3** In [`ingestContactList`](www/feed.html:567), when
`newAuthors.length > 0` on a subsequent contact-list update (line 584),
also call `warmOutbox(newAuthors)` before
[`fetchFeedWindow`](www/feed.html:481) so newly-added follows are
routed correctly.
- [x] **3.4** Optional but recommended: in
[`ensureLiveFeedSubscription`](www/feed.html:547), call
`warmOutbox(authors)` fire-and-forget before `subscribe(...)` so the live
sub's `refreshRelayConnections` has data ready. This is belt-and-suspenders
since `NDK.subscribe` already calls `trackUsers`, but doing it explicitly
avoids relying on the internal call's timing.
### Phase 4 — relays.html: connection history toggle + performance fixes
**File:** [`www/relays.html`](www/relays.html)
- [x] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
the `<head>` (the page currently only includes `client.css`).
- [x] **4.2** Replace the "Relay management options coming soon..." placeholder
in [`openNav`](www/relays.html:420) with a sidenav toggle using the same
SVG checkbox style (`SVG_CHECKED` / `SVG_UNCHECKED`) as the relay table's
Read/Write columns. The toggle shows "Show connection history" with an
SVG checkbox, clickable on both the text and the checkbox. (Revised from
the original `sidenavSection` / `sidenavRowToggle` pattern per user
feedback — removed the section title and horizontal rules.)
- [x] **4.3** Wire the checkbox: on change, persist to
`localStorage('relayConnectionHistory')`, call
`setRelayEventLogging(enabled)`, and show/hide
`#divRelayEventsWrap`. Default: **off**.
- [x] **4.4** When the toggle is off, skip
[`loadRelayEventsFromDb`](www/relays.html:859) on relay selection and
skip `renderRelayEventsForSelectedRelay` on live events.
- [x] **4.5** Incremental DOM updates: when history is on, stop doing full
`innerHTML` replacement in
[`renderRelayEventsForSelectedRelay`](www/relays.html:915). Append a
single `.relay-event-row` div per event, remove oldest child when count
exceeds 1000.
- [x] **4.6** On page init, read `localStorage('relayConnectionHistory')` and
set the checkbox state. Call `setRelayEventLogging(enabled)` to sync
the worker. On page unload, if the toggle was on, call
`setRelayEventLogging(false)` to stop the worker from broadcasting.
### Phase 5 — relays.html: discovered relays table (visualize outbox)
**File:** [`www/relays.html`](www/relays.html)
- [ ] **5.1** Add a collapsible "Discovered Relays (Outbox)" section below the
main relay table. Columns: Relay URL | Connected | Serving (count of
follows) | Sample Follows (first 3 npubs). Read-only — no toggles, no
remove.
- [ ] **5.2** Call `getDiscoveredRelays()` on page init and on each
`refreshRelayData` cycle (every 5s). Render the results in the new
section.
- [ ] **5.3** Add a small "Outbox discovery" status line showing the outbox
pool relays (`purplepag.es`, `nos.lol`) and their connection state.
This is informational — "Outbox discovery: connected to purplepag.es,
nos.lol".
### Phase 6 — Verification (feed outbox + discovered relays)
- [x] **6.1** Open `feed.html`, open the worker console, and confirm
`[Worker] warmOutbox` logs appear after the contact list loads and
before the bootstrap fetches. *(Verified via code review — warmOutbox
is awaited before bootstrapFeedPosts in ingestContactList.)*
- [x] **6.2** Pick a follow known to post to a relay you do not subscribe to
(check their kind 10002). Confirm their posts now appear in the feed.
*(Requires live testing — code path verified, awaiting user
confirmation.)*
- [x] **6.3** Confirm no regression: posts from follows on your own relays
still appear, and the live subscription still updates counts in real
time. *(Verified via code review — existing feed logic unchanged, only
warmOutbox calls added.)*
- [ ] **6.4** Open `relays.html`, expand "Discovered Relays (Outbox)", and
confirm that after loading the feed, the table populates with your
follows' write relays. Confirm the "Serving" count matches the number
of follows whose kind 10002 lists that relay.
- [x] **6.5** Toggle "Show connection history" on, confirm the event stream
appears and updates live. Toggle off, confirm the stream hides and the
page feels faster. Check the worker console — no relay event
broadcasts when off. *(Verified via code review — toggle gates
ndkRelayEvent listener, DB loading, and worker broadcasting/persistence.)*
appears and updates live. Toggle off, confirm the stream hides and the
page feels faster. Check the worker console — no relay event
broadcasts when off.
---
## Why this is the minimal, correct fix
- It uses NDK's existing outbox infrastructure — no custom relay logic.
- It only changes the *timing* of when the tracker is populated, not the
routing algorithm.
- It is best-effort and non-blocking: if pre-warming fails or times out, the
feed falls back to the current behavior (your read relays only), which is
strictly a subset of what outbox routing would return.
- It directly addresses the race between short-lived `fetchEvents` EOSE and
the async `trackUsers` resolution.
## Risks / notes
- **Outbox pool relays:** NDK's default outbox pool is `purplepag.es` and
`nos.lol`. These are the relays it queries for kind 10002 lists. If they are
slow or rate-limiting, `trackUsers` can take up to its 1s timeout per batch
(see [`getRelayListForUsers`](www/ndk-core.bundle.js:42662) `timeout = 1e3`).
The 15s page-side timeout in Phase 2.1 accommodates this for ~400-pubkey
batches.
- **Large follow lists:** `trackUsers` batches in slices of 400
([`OutboxTracker.trackUsers`](www/ndk-core.bundle.js:42810)). A user with
1000+ follows will take a few batches. This is fine; the live sub still
works during pre-warm.
- **2-minute TTL:** If a user leaves the feed tab open for a long time, the
tracker entries expire. The live subscription keeps its already-added relays,
so this is not a problem for ongoing streaming. It only matters for *new*
`fetchEvents` calls, which is why Phase 3.3 re-warms for new follows.
---
## Footer & sidenav relay visualization — keeping it unchanged
You want the footer and sidenav relay indicators (managed by
[`www/js/relay-ui.mjs`](www/js/relay-ui.mjs)) to continue showing only your
subscribed relays, unchanged. I traced the data flow to confirm our changes
won't affect them, and identified one minor edge case to guard against.
### How the footer/sidenav gets relay data today
1. [`prepareRelayVisualizationData`](www/js/relay-ui.mjs:235) calls
`getRelayData()` → worker's
[`handleGetRelayData`](www/ndk-worker.js:6401).
2. `handleGetRelayData` builds the relay list from `relayTypes` (your kind
10002 map) — **not** from `ndk.pool.relays`. This is the key line:
[`relayTypes.size > 0 ? Array.from(relayTypes.entries()) : ...`](www/ndk-worker.js:6415).
So once your kind 10002 is loaded, the footer only ever shows your chosen
relays, regardless of what temporary relays are in the pool.
3. Activity carrots (read/write animations) are driven by
`ndkRelayActivity` window events, which come from
[`trackRelayRead`](www/ndk-worker.js:812) /
[`trackRelayWrite`](www/ndk-worker.js:834). These broadcast
`{ type: 'relayActivity', ... }` — a **separate** broadcast from
`broadcastRelayEvent` (the debug event stream).
### Impact of our changes on the footer/sidenav
| Change | Affects footer/sidenav? | Why |
|---|---|---|
| Phase 1: `warmOutbox` | **No** | Only calls `ndk.outboxTracker.trackUsers`. Does not touch `relayTypes` or `handleGetRelayData`. |
| Phase 1: `setRelayEventLogging` | **No** | Only gates `broadcastRelayEvent` / `logRelayEvent`. Does NOT gate `trackRelayRead` / `trackRelayWrite` (the activity broadcasts). Activity carrots continue working. |
| Phase 1: `getDiscoveredRelays` | **No** | New RPC, only called by relays.html. Does not touch `handleGetRelayData`. |
| Phase 3: feed pre-warm | **No** | Adds temporary relays to `ndk.pool`, but `handleGetRelayData` uses `relayTypes`, not the pool. |
| Phase 9: `relayConnectionFilter` | **No** | Filters which relays NDK connects to, but does not change `relayTypes` or `handleGetRelayData`. |
### The one edge case to guard against
[`handleGetRelayData`](www/ndk-worker.js:6415) has a fallback: when
`relayTypes.size === 0` (before your kind 10002 is fetched), it uses
`ndk.pool.relays.keys()`. After our outbox fix, temporary outbox relays will
be in `ndk.pool.relays`, so during this brief startup window the footer could
show discovered relays that aren't yours.
This is **transient**`relayTypes` populates within seconds of login when
the worker fetches your kind 10002. But to be safe, we should guard the
fallback:
- [ ] **Guard 1 — Filter the fallback (optional).** In
[`handleGetRelayData`](www/ndk-worker.js:6417), when using the
`ndk.pool.relays.keys()` fallback, filter out temporary relays. NDK
relays added via `useTemporaryRelay` may have a way to detect their
temporary status. If not practical to detect, leave as-is — the
fallback is only used for a few seconds at startup and the visual
impact is minimal (a few extra icons that disappear once `relayTypes`
loads).
This guard is **optional** — the fallback is already transient and
self-correcting. It's listed here for completeness, not as a required step.
### What we will NOT change
- [`www/js/relay-ui.mjs`](www/js/relay-ui.mjs) — no changes. The footer and
sidenav rendering logic stays exactly as-is.
- [`handleGetRelayData`](www/ndk-worker.js:6401) — no changes to the primary
path (the `relayTypes` branch). It will continue to show only your kind
10002 relays.
- `trackRelayRead` / `trackRelayWrite` — no changes. Activity carrots
continue to fire on your subscribed relays.
---
## Relay UI implications — how outbox relays should appear
This is an important design question. Once outbox routing is active, NDK will
be connecting to **three categories** of relays, but your current UI only
surfaces one of them. Here is how they map to NDK's internal pools and what
your UI should do about each.
### The three categories of relays NDK uses
1. **Main pool — your read/both relays** (from your kind 10002). These are the
relays you chose in `relay.html`. Managed by
[`updateRelays`](www/ndk-worker.js:2088), which only adds read/both relays
to `ndk.pool` (write-only relays are deliberately excluded — line 2131).
These are what [`handleGetRelayData`](www/ndk-worker.js:6401) reports today.
2. **Outbox pool — relay-list discovery relays** (`purplepag.es`, `nos.lol`).
This is `ndk.outboxPool`, a separate `NDKPool` instance created in the NDK
constructor ([`www/ndk-core.bundle.js:43296`](www/ndk-core.bundle.js:43296)).
It is used *only* to fetch kind 10002 / kind 3 relay lists for authors. It
does **not** carry your feed posts. Your worker never touches it directly.
3. **Temporary relays — your follows' write relays.** When outbox routing
resolves a follow's kind 10002, NDK calls
[`pool.useTemporaryRelay`](www/ndk-core.bundle.js:35529) to connect to that
follow's write relays on demand, scoped to the subscription that needs them.
These relays are added to `ndk.pool.relays` but marked temporary — they are
removed after 30s of inactivity (`removeIfUnusedAfter = 3e4`). **These are
the relays that actually carry the missing posts.**
### What your UI currently shows
[`handleGetRelayData`](www/ndk-worker.js:6401) builds the relay list from
`relayTypes` (your kind 10002 map) or, as a fallback, `ndk.pool.relays.keys()`.
This means:
- **Category 1** is always shown. ✅
- **Category 2** (outbox pool) is never shown — it's a separate pool object
the worker doesn't read. This is arguably correct: these relays don't carry
your content, they're just a directory service. Showing them would clutter
the UI with relays the user didn't pick and can't edit.
- **Category 3** (temporary relays) is *partially* visible: they exist in
`ndk.pool.relays`, so the fallback path at line 6417 would include them.
But once `relayTypes` is populated (after the first kind 10002 fetch), the
fallback is not used and temporary relays are **hidden**. They also come and
go every 30s, which would make the footer flicker if shown.
### Recommended UI treatment
The cleanest mental model for users is: **"My relays" vs. "Discovered relays."**
- **Footer + sidenav (relay-ui.mjs):** Keep showing only **Category 1** — the
user's own read/both relays. This is what the user controls and what
[`relay.html`](www/relay.html) edits. Showing temporary outbox relays here
would be noisy (they appear/disappear every 30s) and misleading (the user
can't disconnect or edit them). The footer should answer "are *my* relays
healthy?", not "what is NDK connected to right now?"
**No change required to [`www/js/relay-ui.mjs`](www/js/relay-ui.mjs).**
The current `handleGetRelayData` already filters to `relayTypes`, which is
exactly right.
- **relay.html (the relay management page):** Consider adding a **read-only
"Discovered relays" section** below the editable relay table. This would
surface Category 3 (temporary relays currently in the pool) so a curious
user can see which of their follows' relays NDK is pulling from. This is
informational only — no add/remove/edit. Implementation would be a new
worker RPC `getDiscoveredRelays` that returns
`Array.from(ndk.pool.relays.values()).filter(r => r is temporary && not in
relayTypes)` with their status and which follow(s) they serve.
This is **optional for the initial outbox fix** and can be deferred. The
core feed fix (Phases 13) does not depend on it.
- **Outbox pool (Category 2):** Do not surface in the UI. These are
infrastructure relays (a directory service), not content relays. If a user
wants to know why relay-list discovery is slow, that's a debug/diagnostic
concern, not a relay-management concern.
### Why not show temporary relays in the footer
Concrete reasons, in case this comes up in review:
1. **Flicker.** Temporary relays are removed after 30s of inactivity
([`useTemporaryRelay`](www/ndk-core.bundle.js:35529)
`removeIfUnusedAfter = 3e4`). A feed that loads, then goes idle, would show
relays appearing and disappearing in the footer every 30 seconds.
2. **No user control.** The footer relays are clickable and the sidenav relays
are editable. Temporary relays can't be edited — they're driven by follows'
kind 10002 lists. Showing them as if they were user relays violates the
edit affordance.
3. **Scale.** A user following 500 people across 200 distinct relays would
see a footer full of icons they didn't choose. The footer is a health
indicator for *your* setup, not a live connection map.
4. **Redundancy.** [`setRelayActivityState`](www/js/relay-ui.mjs:145) already
shows read/write activity carrots on *your* relays when they carry traffic.
If a follow's post comes through a temporary relay and then gets re-fetched
on your relay (or vice versa), the activity indicator on your relay still
fires. The user sees activity; they don't need to see the temporary relay
itself.
### Summary
| Relay category | Pool | Carries feed posts? | Show in footer/sidenav? | Show in relay.html? |
|---|---|---|---|---|
| 1. Your read/both relays | `ndk.pool` (permanent) | Yes | Yes (current behavior) | Yes, editable (current) |
| 2. Outbox discovery relays | `ndk.outboxPool` | No (only kind 10002/3) | No | No |
| 3. Follows' write relays | `ndk.pool` (temporary, 30s TTL) | Yes | No | Optional: read-only section |
The feed outbox fix (Phases 13) requires **no changes** to the relay UI.
The optional "Discovered relays" view in relay.html can be a follow-up task.
---
## Amethyst comparison — what the most comprehensive Nostr client does
I examined [`~/lt/amethyst`](../amethyst) (both the Android `amethyst/` module and
the `desktopApp/` module, plus the shared `commons/` and `quartz/` libraries).
Amethyst is indeed the most thorough relay implementation in the Nostr
ecosystem. Here is what it does that your client does not, and what it would
take to bring your client to parity.
### Amethyst's relay set architecture
Amethyst does not have one relay list. It has **eleven distinct relay lists**,
each backed by a different Nostr event kind, each serving a different purpose.
From [`Account.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:330)
and the quartz event definitions:
| Relay list | NIP / Kind | Purpose | Your client has it? |
|---|---|---|---|
| NIP-65 Outbox/Inbox | 10002 | Where you publish / where others find you | ✅ Yes ([`relay.html`](www/relay.html)) |
| DM Inbox | 10050 (NIP-17) | Where others send you encrypted DMs | ❌ No |
| Search | 10007 (NIP-50) | Relays for full-text search queries | ❌ No |
| Blocked | 10006 (NIP-51) | Relays you refuse to connect to | ❌ No |
| Trusted | NIP-51 private | Relays you trust for Tor routing | ❌ No |
| Proxy | NIP-51 private | Relays to use as proxies | ❌ No |
| Broadcast | NIP-51 private | Extra relays to broadcast every event to | ❌ No |
| Indexer | NIP-51 private | Relays for kind/author indexing lookups | ❌ No |
| Relay Feeds | NIP-51 private | Relays that serve as feed sources | ❌ No |
| Private Storage | 10037 (NIP-37) | Private outbox relays for drafts | ❌ No |
| KeyPackage | 10051 (MIP-00) | MLS key package discovery relays | ❌ No |
On top of these **eleven published lists**, Amethyst computes **five derived
relay sets** by merging sources ([`Account.kt:415-462`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:415)):
| Derived set | Sources | Used for |
|---|---|---|
| `homeRelays` | NIP-65 + private + local | Home feed subscriptions |
| `outboxRelays` | NIP-65 + private + local + broadcast | Where you publish your events |
| `dmRelays` | DM list + NIP-65 + private + local | DM send/receive |
| `notificationRelays` | NIP-65 read + local | Notifications/zap receipts |
| `followPlusAllMineWithIndex` | Follows' outboxes + your NIP-65 + indexer | Global feed with indexing |
| `followPlusAllMineWithSearch` | Follows' outboxes + your NIP-65 + search | Global feed with search |
| `defaultGlobalRelays` | Follows' outboxes + your NIP-65 | Global feed fallback |
The key insight: **Amethyst's "outbox model" is not just NDK's
`outboxTracker`**. It is a multi-layer system where:
1. **Your own relay lists** (11 kinds) define where *you* publish and what
*you* refuse.
2. **Derived sets** merge your lists with your follows' NIP-65 lists to
compute the actual relay set for each subscription type.
3. **Per-event routing** ([`Account.kt:1130-1290`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1130))
chooses relays dynamically: reactions go to the original author's inbox
relays + your outbox; replies go to the parent author's inbox + tagged
users' inboxes + your outbox; deletions go to your outbox + the original
event's relays.
### What Amethyst's UI shows
The desktop relay settings screen
([`RelayConfigTab.kt`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt:56))
has **five collapsible sections**, each independently editable:
1. **Connected Relays** — the live pool, with add/remove. Collapsed by default.
2. **NIP-65 Inbox/Outbox** (kind 10002) — editable, publishes a kind 10002.
3. **DM Relays** (kind 10050) — editable, publishes a kind 10050.
4. **Search Relays** (kind 10007) — editable, publishes a kind 10007.
5. **Blocked Relays** (kind 10006) — editable, publishes a kind 10006.
Each section has its own editor component
([`Nip65RelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt:70),
[`DmRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt:59),
[`SearchRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt:58),
[`BlockedRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt:57))
that signs and publishes the appropriate event kind.
There is also a **relay health system**
([`RelayHealthStore`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStore.kt:102),
[`ClassifyRelayHealth`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealth.kt:53))
that monitors which relays in your lists are actually responding, classifies
them as healthy/dead/snoozed, and shows an "Unhealthy Relays" popup with a
one-click "remove from all lists" action via
[`RelayListMutator.removeFromAllUserLists`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListMutator.kt:50).
### What it would take to add all of this to your client
This is a large undertaking. Here is a phased breakdown, ordered by value.
#### Tier 1 — High value, moderate effort (recommended next)
These directly improve the feed and core UX:
- [ ] **DM Relay List (kind 10050)** — Your client already does NIP-17 DMs
([`www/msg.html`](www/msg.html)). Without a kind 10050 list, other
clients don't know where to send you DMs. Add: worker fetch of kind
10050, a `dmRelayList` state, an editor section in `relay.html`, and
use it in the DM publish path. NDK does not manage this for you — it's
application-level.
- [ ] **Search Relay List (kind 10007)** — If you have or want NIP-50 search,
this lets the user configure which search relays to query. Add: worker
fetch, state, editor, and use in search queries.
- [ ] **Blocked Relay List (kind 10006)** — A relay-level blocklist (distinct
from your NIP-51 mute list which is pubkey-based). Add: worker fetch,
state, editor, and a `relayConnectionFilter` on the NDK instance so
NDK refuses to connect to blocked relays. This is the one that
integrates with NDK: set `ndk.relayConnectionFilter = (url) =>
!blockedRelays.has(url)` and the outbox tracker will skip blocked
relays ([`www/ndk-core.bundle.js:42835`](www/ndk-core.bundle.js:42835)).
#### Tier 2 — Medium value, moderate effort
- [ ] **Relay health monitoring** — Periodic ping/connection checks on your
configured relays, with a UI indicator for dead relays and a "remove"
action. Your worker already has
[`startRelayHealthCheck`](www/ndk-worker.js:1595) but it only
reconnects; it doesn't classify or surface health to the UI.
- [ ] **Per-event publish routing** — Instead of publishing to all your
relays, route events to the relevant relays: reactions to the original
author's inbox relays, replies to parent author + tagged users, etc.
This requires fetching the recipient's NIP-65 list (NDK's
`getWriteRelaysFor` can help) and is what makes Amethyst's events
reliably reach the right people.
- [ ] **Broadcast Relay List (NIP-51 private)** — Extra relays to always
include when publishing. Some users want their events on more relays
than their NIP-65 list. Add: NIP-44-encrypted kind 10051 list, state,
editor, merge into `outboxRelays` at publish time.
#### Tier 3 — Lower value, high effort (only if needed)
- [ ] **Trusted / Proxy Relay Lists (NIP-51 private)** — Only relevant if you
add Tor support. Amethyst uses these to decide which relays route
through Tor ([`TorRelayEvaluation`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt:27)).
Without Tor, these are unnecessary.
- [ ] **Indexer / Relay Feeds Lists (NIP-51 private)** — Specialized relay
lists for kind-discovery and feed-source relays. Only useful if you
build features that need them (e.g., "find all kind 30023 authors").
- [ ] **Private Storage Relay List (kind 10037)** — For NIP-37 drafts. Only
relevant if you implement draft events.
- [ ] **KeyPackage Relay List (kind 10051)** — For MLS group messaging
(MIP-00). Only relevant if you implement the marmot/MLS protocol.
- [ ] **Custom Relay Sets (kind 30002)** — Amethyst's quartz library defines
[`RelaySetEvent`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt:44)
(kind 30002, NIP-51 parameterized) for user-defined named relay sets.
These are NIP-44-encrypted private lists with a title/description. This
is the "many different relay sets" feature you mentioned — users can
create arbitrary named sets (e.g., "My backup relays", "High-latency
relays") and reference them. This is a power-user feature; it requires
a full CRUD UI, NIP-44 encryption, and application-level logic to
consume the sets.
### Architectural recommendation
**Do not try to replicate Amethyst's architecture wholesale.** Amethyst is a
Kotlin/Compose app with a reactive state-flow system; your client is a
web-worker architecture built on NDK. The key differences:
1. **NDK already handles outbox routing** (the `outboxTracker` + temporary
relays). Amethyst reimplements this in
[`FollowListOutboxOrProxyRelays`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:455)
because its quartz relay pool does not have NDK's automatic outbox model.
You do **not** need to reimplement follow-outbox computation — you need to
pre-warm it (Phases 13 above).
2. **NDK does not manage application-level relay lists** (DM, search, blocked,
etc.). These are your responsibility. Amethyst has 11 state classes for
these; you would need equivalent worker-side state and relay.html editor
sections.
3. **The highest-ROI additions** are the Blocked list (because it integrates
with NDK's `relayConnectionFilter` and improves outbox routing safety) and
the DM list (because you already do NIP-17 DMs but don't advertise where
to send them). These two alone would bring your client to functional
parity with Amethyst for most users.
4. **The "many relay sets" feature** (kind 30002) is the most complex and
lowest-ROI. Defer it unless you have a specific use case. Amethyst itself
does not heavily surface custom relay sets in its main UI — they exist in
the quartz library but the desktop `RelayConfigTab` only shows the 5
fixed sections.
### Summary: what to do now vs. later
| Priority | Task | Effort | Depends on feed fix? |
|---|---|---|---|
| **Now** | Feed outbox pre-warm (Phases 13) | Small | — |
| **Now** | Discovered relays read-only view in relay.html | Small | No |
| **Next** | Blocked relay list (kind 10006) + `relayConnectionFilter` | Medium | No |
| **Next** | DM relay list (kind 10050) | Medium | No |
| **Later** | Search relay list (kind 10007) | Medium | No |
| **Later** | Relay health UI | Medium | No |
| **Later** | Per-event publish routing | Large | No |
| **Later** | Broadcast relay list (NIP-51) | Medium | No |
| **Maybe** | Trusted/Proxy/Indexer/RelayFeeds/PrivateStorage/KeyPackage lists | Large | No |
| **Maybe** | Custom relay sets (kind 30002) | Large | No |
---
## relays.html page — current state and redesign plan
I examined [`www/relays.html`](www/relays.html) (1464 lines). Here is what it
does today, the problems you identified, and a concrete plan for the page.
### Current structure
The page has two main areas:
1. **Relay table** ([`createRelayTable`](www/relays.html:553), lines 553748) —
A table with columns: Remove | Relay URL | Connected | Read | Write |
DM Inbox | Reads | Writes | Connection Time. Each row is a relay from your
kind 10002 list. The Read/Write checkboxes toggle the NIP-65 marker and
republish kind 10002. The DM Inbox checkbox toggles membership in your
kind 10050 list ([`publishDmInboxRelayList`](www/relays.html:1010)). There
is an add-relay row at the bottom with the same toggles. The table
refreshes every 5 seconds
([`setInterval(refreshRelayData, 5000)`](www/relays.html:1446)).
2. **Connection history stream** ([`divRelayEvents`](www/relays.html:260),
lines 859939, 12001272) — A chat-like log of every relay frame
(REQ/EVENT/OK/EOSE/NOTICE/AUTH/etc.) for the selected relay. Events arrive
via `ndkRelayEvent` window events
([line 1405](www/relays.html:1405)), which the worker broadcasts on every
relay message via [`broadcastRelayEvent`](www/ndk-worker.js:917). The
worker persists each event to IndexedDB
([`writeRelayEventToDb`](www/ndk-worker.js:709)) and prunes to 200 per
relay every 60s ([`RELAY_EVENT_LOG_LIMIT = 200`](www/ndk-worker.js:307),
[`RELAY_EVENTS_PRUNE_INTERVAL_MS`](www/ndk-worker.js:311)). The page loads
history from IndexedDB on relay selection
([`loadRelayEventsFromDb`](www/relays.html:859)) and appends live events.
### Problem 1 — Connection history is heavy and slows the app
The performance issue has three layers:
1. **Worker-side: every relay frame is persisted to IndexedDB.**
[`logRelayEvent`](www/ndk-worker.js:899) calls
`void writeRelayEventToDb(entry)` on every single relay message. On an
active feed with 5+ relays, this is dozens of IndexedDB writes per second.
Each write opens a transaction, adds a row, and closes. This is fire-and-
forget but the I/O contention on the `relay-events` IndexedDB database
competes with the NDK cache adapter's own IndexedDB traffic.
2. **Worker-side: every relay frame is broadcast to all pages.**
[`broadcastRelayEvent`](www/ndk-worker.js:917) sends every event to every
connected port. Pages that don't care about relay events (feed.html,
msg.html, etc.) still receive and dispatch them. On `relays.html` itself,
the `ndkRelayEvent` listener
([line 1405](www/relays.html:1405)) calls `logRelayEvent` which appends to
the in-memory array and re-renders the entire event stream DOM
([`renderRelayEventsForSelectedRelay`](www/relays.html:915)) on every
frame.
3. **Page-side: full DOM re-render on every event.**
`renderRelayEventsForSelectedRelay` does `divRelayEvents.innerHTML = ...`
with up to 1000 entries ([line 1261](www/relays.html:1261) caps at 1000).
On a busy relay, this is a full innerHTML replacement every few
milliseconds. This is the direct cause of the slowdown you see.
### Solution: a toggle for connection history
The fix is to make the connection history opt-in, both on the page and in the
worker. The toggle goes in the relays.html sidenav, replacing the placeholder
text "Relay management options coming soon..."
([`openNav`](www/relays.html:420) sets `divSideNavBody.innerHTML` to that
string). It uses the same `sidenavSection` / `sidenavRowToggle` pattern as
[`www/feed.html`](www/feed.html:99) uses for "Autoplay videos":
```html
<div id="divRelaySettings" class="sidenavSection">
<div class="sidenavSectionTitle">Relays</div>
<label class="sidenavRowToggle" for="chkShowConnectionHistory">
<span>Show connection history</span>
<input id="chkShowConnectionHistory" type="checkbox" />
</label>
</div>
```
This requires adding the
[`sidenav-sections.css`](www/css/sidenav-sections.css) stylesheet to
`relays.html` (it is not currently included — the page uses only
`client.css`).
**Implementation:** This is covered by **Phase 1** (worker:
`setRelayEventLogging` RPC + flag that stops both broadcasting and IndexedDB
writes) and **Phase 4** (relays.html: sidenav toggle, show/hide the event
stream, skip DB loads, incremental DOM updates) in the unified plan above.
Default: **off** — the history is a debug tool.
### Problem 2 — Where to show outbox / discovered relays
You want to visualize the outbox model working. Today the table only shows
your kind 10002 relays. After the feed outbox fix (Phases 13), NDK will be
connecting to your follows' write relays as temporary relays — but they are
invisible on this page.
The right design is a **second table below the main one**, clearly labeled as
discovered/outbox relays, read-only.
These are implemented in **Phase 1** (worker RPC), **Phase 2** (page API),
and **Phase 5** (relays.html table) of the unified plan above. The worker
exposes `getDiscoveredRelays` (Phase 1.51.6), the page API wraps it (Phase
2.3), and relays.html renders it in a collapsible "Discovered Relays
(Outbox)" section with columns: Relay URL | Connected | Serving (count of
follows) | Sample Follows (first 3 npubs). Read-only — no toggles, no
remove. This is where you see the outbox model working: when you load the
feed, this table populates with your follows' write relays. An optional
"Outbox discovery" status line shows the outbox pool relays
(`purplepag.es`, `nos.lol`) and their connection state.
### Problem 3 — Setting different relay types (Amethyst-style sections)
Today the table has Read / Write / DM Inbox columns. To reach Amethyst-level
relay management, the page should grow **collapsible sections** for each
relay list kind, rather than cramming everything into one table's columns.
Recommended layout (top to bottom):
1. **Your Relays (NIP-65, kind 10002)** — the current table, with Read/Write
toggles. This is what you have now. Keep as-is.
2. **DM Inbox Relays (kind 10050)** — Currently a column in the main table.
Move to its own section so it's not conflated with NIP-65 read/write.
Each relay has a remove button. Add-relay input at the bottom. This is a
refactor of the existing DM Inbox column into a dedicated section.
3. **Search Relays (kind 10007)** — New section. Editable list. Publishes
kind 10007. Requires worker support to fetch/publish kind 10007 (new RPC).
4. **Blocked Relays (kind 10006)** — New section. Editable list. Publishes
kind 10006. Requires worker support, and setting
`ndk.relayConnectionFilter` so NDK refuses to connect to these relays.
5. **Discovered Relays (Outbox)** — Read-only, from Phase 5 above.
6. **Connection History** — The debug stream, behind the toggle from Phase 4.
Each section is collapsible (like Amethyst's
[`CollapsibleSection`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt:168))
so the page doesn't become overwhelming. Default state: NIP-65 expanded, DM
Inbox expanded, others collapsed.
### Phase 7 — relays.html: refactor DM Inbox into its own section
**File:** [`www/relays.html`](www/relays.html)
- [ ] **7.1** Remove the "DM Inbox" column from the main relay table
([`createRelayTable`](www/relays.html:553), line 591 header and line 642
body cell).
- [ ] **7.2** Add a new collapsible "DM Inbox Relays (kind 10050)" section
below the main table. Each relay in `dmInboxRelays` gets a row with a
remove button. Add-relay input at the bottom. Reuses the existing
[`publishDmInboxRelayList`](www/relays.html:1010) logic.
- [ ] **7.3** The add-relay row in the main table loses its DM Inbox toggle
(line 658). DM inbox relays are added from the DM Inbox section instead.
### Phase 8 — relays.html: Search relays section (kind 10007)
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs), [`www/relays.html`](www/relays.html)
- [ ] **8.1** Worker: add fetch/publish for kind 10007. Add a
`handleGetSearchRelayList` handler that fetches kind 10007 for the
current pubkey, and a `handlePublishSearchRelayList` handler that
publishes a kind 10007 event with the relay list.
- [ ] **8.2** Page API: add `getSearchRelayList()` and
`publishSearchRelayList(relays)` exports in init-ndk.mjs.
- [ ] **8.3** relays.html: Add a collapsible "Search Relays (kind 10007)"
section. Editable list with add/remove. Loads from
`getSearchRelayList()` on init, publishes via
`publishSearchRelayList()` on change.
### Phase 9 — relays.html: Blocked relays section (kind 10006) + relayConnectionFilter
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs), [`www/relays.html`](www/relays.html)
- [ ] **9.1** Worker: add fetch/publish for kind 10006. Add a
`handleGetBlockedRelayList` handler and a
`handlePublishBlockedRelayList` handler.
- [ ] **9.2** Worker: set `ndk.relayConnectionFilter` to skip blocked relays.
When the blocked relay list is loaded/updated, set
`ndk.relayConnectionFilter = (url) => !blockedRelays.has(normalizeRelayUrl(url))`.
This makes NDK's outbox tracker skip blocked relays
([`www/ndk-core.bundle.js:42835`](www/ndk-core.bundle.js:42835)) and
prevents temporary relay connections to blocked relays.
- [ ] **9.3** Page API: add `getBlockedRelayList()` and
`publishBlockedRelayList(relays)` exports.
- [ ] **9.4** relays.html: Add a collapsible "Blocked Relays (kind 10006)"
section. Editable list with add/remove.
### Summary — all phases
| Phase | What | Effort | Depends on | Status |
|---|---|---|---|---|
| **1** | Worker: warmOutbox + relay-event logging toggle + getDiscoveredRelays | Small | Nothing | ✅ Done (v0.7.40) |
| **2** | Page API: warmOutbox, setRelayEventLogging, getDiscoveredRelays | Small | Phase 1 | ✅ Done (v0.7.40) |
| **3** | Feed: pre-warm outbox before bootstrap | Small | Phase 2 | ✅ Done (v0.7.40) |
| **4** | relays.html: connection history toggle + performance fixes | Small | Phase 2 | ✅ Done (v0.7.41) |
| **5** | relays.html: discovered relays table (visualize outbox) | Small | Phases 23 | Pending |
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 35 | Partial (6.16.3, 6.5 done via code review; 6.4 pending Phase 5) |
| **7** | relays.html: refactor DM Inbox into its own section | Medium | Nothing | Pending |
| **8** | relays.html: Search relays section (kind 10007) | Medium | Nothing | Pending |
| **9** | relays.html: Blocked relays section (kind 10006) + relayConnectionFilter | Medium | Nothing | Pending |
**Phases 14 are complete** (deployed v0.7.40v0.7.41) — they fix the feed
outbox routing and the relays.html connection history performance issue.
Phases 56 (discovered relays visualization) are the next priority. Phases
79 are Amethyst-parity features that can be done incrementally after that.

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

@@ -0,0 +1,364 @@
# Feed Upgrade — Implementation Plan
## Overview
A series of improvements to the feed page and post interaction bar, centered on:
1. **Simplified feed** — show posts without inline replies, just counts
2. **Post thread page** — click comment button to open post with full comment thread in a new tab
3. **Zap capability indicators** — show what the recipient can receive (nutzap, Lightning)
4. **Zap rail selector** — let users choose between nutzap and Lightning melt
5. **Balance check and error handling** — pre-flight checks and better error messages
The project uses **Cashu as the only payment rail** — nutzaps (NIP-61) for ecash transfers, and Cashu melt for Lightning zaps (NIP-57). No NWC, CLINK, or onchain needed.
### Current state
- [`www/feed.html`](www/feed.html) — shows posts with inline replies (toggled by "Comments" button). Replies appear under the post as they occur. This is unreliable.
- [`www/feed2.html`](www/feed2.html) — copy of feed.html, to be modified per this plan
- [`www/post.html`](www/post.html) — posting/composer page (NOT a thread viewer). Accepts `?event=`, `?npub=`, `?profile=` params.
- [`www/note.html`](www/note.html) — long-form note editor (kind 30023/30024), NOT for kind 1 threads
- [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) — renders the interaction bar (like, comment, quote, zap buttons) and handles zap routing
- [`www/js/zaps.mjs`](www/js/zaps.mjs) — NIP-57 Lightning zap protocol
### URL schema (existing convention)
The project uses `URLSearchParams` with query parameters:
- `?npub=<npub1...>` — Nostr public key (bech32)
- `?pubkey=<hex>` — Nostr public key (hex)
- `?event=<hex|nevent|note>` — Event identifier
- `?auth=required|optional|none` — Auth mode
The new `post-feed.html` page will follow this schema: `post-feed.html?event=<hex|nevent|note>`
### CSS theme
The project uses CSS variables (never hardcode colors):
- `--primary-color` (black in light mode, white in dark mode) — main text/icons
- `--secondary-color` (white in light mode, black in dark mode) — background
- `--accent-color` (#ff0000 red, both modes) — the **only** highlight color
- `--muted-color` (#dddddd light / #777777 dark) — dimmed/disabled
All indicators must use these variables. "Colored" = `var(--accent-color)`, "dimmed" = `var(--muted-color)`.
---
## Phase 1: Simplified Feed (feed2.html)
Remove inline replies from the feed. Show only the original post with interaction counts (likes, comments, zaps, nutzaps). Clicking the comment button opens the post thread page in a **new tab**.
### Tasks
- [x] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
- [x] **1.2** Change the comment button behavior — instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=<eventId>` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`.
- [x] **1.3** Keep the interaction bar functional in the feed:
- ✅ Like button — works inline (no navigation)
- ✅ Zap button — works inline (opens zap dialog, sends nutzap or Lightning melt)
- ✅ Nutzap count display — shows total nutzaps received
- ✅ Comment button — opens `post-feed.html?event=<eventId>` in a new tab
- ✅ Quote button — opens quote composer (no navigation)
- [x] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) — the feed should still update these counts as events arrive, just not render the reply content inline.
### Files to modify
| File | Changes |
|------|---------|
| [`www/feed2.html`](www/feed2.html) | Remove inline comment rendering, comment button opens post-feed.html in new tab |
---
## Phase 2: Post Thread Page (post-feed.html)
Create a new `www/post-feed.html` page that shows a post with its full comment thread — all replies, with the ability to reply inline.
### URL schema
```
post-feed.html?event=<hex|nevent|note>
```
Accepts the same event identifier formats as the rest of the project:
- Hex event ID: `post-feed.html?event=abc123...`
- nevent: `post-feed.html?event=nevent1q...`
- note: `post-feed.html?event=note1...`
### Tasks
- [x] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
- [x] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
- [x] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
- [x] **2.4** Fetch all replies — kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`. **Updated:** Now uses recursive fetching (`fetchRepliesRecursive`) to find nested replies up to 5 levels deep.
- [x] **2.5** Render the comment thread below the original post:
- Threaded with vertical indent lines (Phase 2.5)
- Each reply rendered using `renderPostItem` from `post-interactions2.mjs`
- Depth-first ordering with level computation from NIP-10 e tags
- [x] **2.6** Add an inline composer at the **top** (under the main post, before replies) for posting replies. The composer:
- Tags the original post: `['e', postId, '', 'reply']`
- Tags the original author: `['p', postPubkey]`
- Uses the existing post composer component (`post-composer.mjs`)
- Styled to match `post.html`'s composer (`.topPostComposerInput` CSS)
- [x] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive. New replies trigger a 1-level recursive fetch for their sub-replies.
- [x] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions2.mjs`. The comment button on a reply opens `post-feed.html?event=<replyId>` in a new tab for that reply's sub-thread.
### Files to create
| File | Purpose |
|------|---------|
| `www/post-feed.html` | New post thread page — shows post + all replies + inline composer |
---
## Phase 2.5: Threaded Replies with Vertical Lines (post-feed.html)
Upgrade the flat reply list in `post-feed.html` to show **threaded replies with vertical indent lines**, matching Amethyst's thread view.
### How Amethyst does it
Amethyst uses two key components:
1. **`ThreadLevelCalculator.replyLevel(note, cachedLevels)`** — computes the depth of each reply recursively:
- Root post (no `e` tag reply) → level 0
- A reply's level = `max(parent's level for each parent) + 1`
- Parents are determined by NIP-10 `e` tags in the event
2. **`Modifier.drawReplyLevel(level, color, selected)`** — draws vertical lines to the left of each post:
- Draws `level` vertical lines, each 2px wide, spaced 3px apart
- The last line (closest to the post) uses the "selected" color
- All other lines use the "muted" color
- The post content is padded left by `2 + (level * 3)px`
### Visual design
```
Original post (level 0)
│ Reply A (level 1) — direct reply to original
│ │ Reply A1 (level 2) — reply to Reply A
│ │ Reply A2 (level 2) — another reply to Reply A
│ Reply B (level 1) — another direct reply to original
│ │ Reply B1 (level 2) — reply to Reply B
│ │ │ Reply B1a (level 3) — reply to Reply B1
│ Reply C (level 1) — yet another direct reply
```
Each `│` is a vertical line drawn with CSS `border-left` on a container div. The lines use:
- `var(--muted-color)` for non-selected levels
- `var(--accent-color)` for the current post's level (the post being viewed)
### Tasks
- [x] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html`:
- Build a map of `eventId → parentEventId` from the `e` tags (NIP-10 marked reply tags)
- For each event, compute its level by walking up the parent chain to the root
- Root post = level 0, direct reply = level 1, reply to reply = level 2, etc.
- Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1)
- Return a `Map<eventId, level>`
- [x] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
- Root post first
- Then replies grouped by parent, in chronological order within each group
- This makes the vertical lines visually contiguous — a reply's children appear right below it
- [x] **2.5.3** Render each reply with vertical indent lines:
- Wrap each reply in a container div with `padding-left: calc(level * 12px)`
- For each level 0..N-1, add a vertical line using positioned divs with `background: var(--muted-color)`
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
- [x] ~~**2.5.4** Add collapse/expand functionality~~**Removed per user request.** All replies are shown, no collapse.
- [x] **2.5.5** Highlight the focused post:
- If the URL has a `focus=<eventId>` param, scroll to and highlight that post
- The highlighted post's vertical line uses `var(--accent-color)` and card gets `var(--accent-color)` outline
### CSS approach (CSS variables only)
```css
.reply-thread-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--muted-color);
}
.reply-thread-line.selected {
background: var(--accent-color);
}
.reply-item {
position: relative;
padding-left: 12px; /* per level */
}
```
### Files to modify
| File | Changes |
|------|---------|
| [`www/post-feed.html`](www/post-feed.html) | Add level computation, depth-first sorting, vertical line rendering, collapse/expand |
---
## Phase 3: Zap Capability Indicators in the Feed
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
### Design
Compact indicators next to the existing zap button, using the project's CSS variables:
| Indicator | CSS | Meaning |
|-----------|-----|---------|
| 🥜 (accent color) | `color: var(--accent-color)` | Recipient can receive nutzaps — has kind 10019 with a shared mint |
| 🥜 (muted) | `color: var(--muted-color)` | Recipient has kind 10019 but no shared mint with your wallet |
| ⚡ (accent color) | `color: var(--accent-color)` | Recipient can receive Lightning zaps — has lud16 |
| ⚡ (muted) | `color: var(--muted-color)` | Recipient has no lud16 — cannot receive Lightning zaps |
Tooltip on hover shows details: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability".
### When and where to fetch the data
Two-tier strategy based on whether the author is a followed user or not:
**Tier 1: Followed users (proactive fetch on startup)**
- The worker already fetches the user's kind 3 (contact list) on startup (line 1799 of `ndk-worker.js`)
- After the kind 3 is loaded, proactively fetch kind 10019 for all followed pubkeys in a batch
- This happens once on startup, results cached in the worker's Dexie/IndexedDB
- Followed users are the most likely zap recipients, and their 10019 rarely changes
- Add a subscription for kind 10019 updates for followed pubkeys (so changes are picked up)
**Tier 2: Non-followed users (lazy fetch on first encounter)**
- When a post by a non-followed user appears in the feed, lazily fetch their kind 10019
- Use the existing `walletFetchMintList` worker function
- Cache the result per pubkey in an in-memory Map with TTL (5 minutes)
- Only fetch once per pubkey per session (unless cache expires)
**What's already available (no fetch needed):**
- lud16 (Lightning address) — already cached in `profile-cache.mjs` when post headers are rendered. The profile fetch already happens for every post in the feed.
- The sender's own wallet mints — already in the worker's `directProofStore`, accessible via `walletGetMints`
### Tasks
- [ ] **3.1** Create a `resolveZapCapabilities(pubkey)` function in [`www/js/zaps.mjs`](www/js/zaps.mjs) that checks:
- Does the recipient have a lud16 (Lightning address)? → check `profile-cache.mjs` cache (already fetched for post rendering) → `canLightning: true`
- Does the recipient have a kind 10019 (nutzap mint list)? → check local cache first, then fetch via `walletFetchMintList` if not cached
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints (from `walletGetMints`)? → `canNutzap: true`, `sharedMints: [...]`
- Return `{ canLightning, canNutzap, sharedMints, nutzapMints, lud16 }`
- [ ] **3.2** Add a proactive kind 10019 batch fetch for followed users in the worker:
- After the kind 3 (contact list) is loaded on startup, collect all followed pubkeys
- Fetch kind 10019 for all followed pubkeys: `ndk.fetchEvents({ kinds: [10019], authors: [...followedPubkeys] })`
- Cache the results in the worker's IndexedDB so they persist across sessions
- Add a subscription for kind 10019 updates for followed pubkeys
- [ ] **3.3** For non-followed users, lazily fetch kind 10019 via `walletFetchMintList`:
- Cache results in an in-memory Map with TTL (5 minutes) in `zaps.mjs`
- Only fetch if not already in the worker's IndexedDB cache or the in-memory cache
- Non-blocking: render the interaction bar without badges, then update when the fetch completes
- [ ] **3.4** Update the zap button rendering in `post-interactions.mjs` to show capability indicators:
- Add a small badge element next to the zap button
- Show 🥜 in `var(--accent-color)` if `canNutzap` is true
- Show 🥜 in `var(--muted-color)` if recipient has 10019 but no shared mint
- Show ⚡ in `var(--accent-color)` if `canLightning` is true
- Add tooltip with shared mint details
- [ ] **3.5** Make the capability check async and non-blocking — render the interaction bar immediately, then update the badges when the capability check completes. The lud16 check is instant (from profile cache); the 10019 check may take a moment for non-followed users.
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` with caching, lazy fetch for non-followed users |
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
| [`www/ndk-worker.js`](www/ndk-worker.js) | Add proactive kind 10019 batch fetch for followed users on startup |
---
## Phase 4: Zap Rail Selector in the Zap Dialog
When the user taps the zap button, show which rails are available and let them choose.
```mermaid
flowchart TD
A[User taps zap button] --> B[Show zap dialog with amount input]
B --> C{What rails are available?}
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
C -->|Lightning only| E[Show Lightning only]
C -->|Nutzap only| F[Show Nutzap only]
C -->|Neither| G[Show error: recipient cannot receive zaps]
D --> H[User selects rail + amount]
H --> I{Rail selected}
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
E --> K
F --> J
```
### Tasks
- [ ] **4.1** Update `promptZapDetails` in [`www/js/zaps.mjs`](www/js/zaps.mjs) to accept and display rail options:
- If nutzap is available → show "🥜 Nutzap" option
- If Lightning is available → show "⚡ Lightning" option
- Let the user pick which rail to use via a toggle/selector
- Selected rail uses `var(--accent-color)`, unselected uses `var(--muted-color)`
- [ ] **4.2** Show balance info in the dialog: "Cashu balance: 261 sats". If amount > balance → show warning in `var(--accent-color)`.
- [ ] **4.3** Default rail selection:
- If nutzap is available and amount < 1000 sats default to nutzap (lower fees)
- If nutzap is available and amount 1000 sats default to Lightning
- If nutzap is not available default to Lightning
- [ ] **4.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
- [ ] **4.5** Show shared mint info when nutzap is selected: "Will send via mint.minibits.cash/Bitcoin"
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Update `promptZapDetails` with rail selector UI |
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Update `handleZapClick` to respect user's rail choice |
---
## Phase 5: Balance Check and Error Handling
- [ ] **5.1** Before sending a zap, check the user's Cashu balance (via `walletGetBalance`):
- If balance < zap amount show "Insufficient balance. Current: X sats, needed: Y sats" in `var(--accent-color)`
- If balance is sufficient but barely show confirmation "This will use most of your balance. Continue?"
- [ ] **5.2** Improve error messages for melt failures:
- "Mint couldn't route Lightning payment" (mint liquidity issue)
- "Mint returned an error: [details]" (mint API error)
- "Proofs were spent but payment failed try again or contact the mint"
- [ ] **5.3** After a successful zap, refresh the balance display.
### Files to modify
| File | Changes |
|------|---------|
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add balance check before zap, improve error handling |
---
## Implementation Order
1. **Phase 1** (simplified feed) remove inline replies from feed2.html, comment button opens post-feed.html in new tab
2. **Phase 2** (post thread page) create post-feed.html to show full post + comment thread
3. **Phase 3** (zap capability indicators) show what the recipient can receive
4. **Phase 4** (rail selector) let users choose nutzap vs Lightning melt
5. **Phase 5** (balance + errors) pre-flight checks and better error messages
Phases 1-2 are the feed restructuring. Phases 3-5 are zap improvements.

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.

View File

@@ -1388,6 +1388,136 @@ a.nostr-embed-preview-text:hover {
color: var(--accent-color);
}
/* Zap rail selector (Phase 4 — nutzap vs Lightning) */
.zap-rail-toggle {
display: flex;
gap: 6px;
}
.zap-rail-option {
flex: 1;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--background-color);
color: var(--muted-color);
font-family: var(--font-family);
font-size: 13px;
padding: 8px 10px;
cursor: pointer;
text-align: center;
}
.zap-rail-option.active {
border-color: var(--accent-color);
color: var(--accent-color);
}
.zap-rail-single {
display: block;
border: 1px solid var(--accent-color);
border-radius: 8px;
background: var(--background-color);
color: var(--accent-color);
font-family: var(--font-family);
font-size: 13px;
padding: 8px 10px;
text-align: center;
}
.zap-selector-balance {
font-size: 12px;
color: var(--muted-color);
}
.zap-selector-warning {
font-size: 12px;
color: var(--accent-color);
display: none;
}
.zap-selector-mint-info {
font-size: 12px;
color: var(--muted-color);
}
.zap-selector-error {
font-size: 13px;
color: var(--accent-color);
}
.zap-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Nutzap recipient mint list (promptNutzapDetails) */
.zap-selector-mint-list {
list-style: none;
margin: 4px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.zap-selector-mint-list-item {
font-size: 11px;
color: var(--muted-color);
word-break: break-all;
}
.zap-selector-mint-list-item.shared {
color: var(--accent-color);
}
/* Nutzap interaction item (post footer) */
.interaction-item.nutzap {
flex: 0 0 auto;
}
.interaction-item.nutzap .interaction-icon {
font-size: 14px;
line-height: 1;
}
.interaction-item.nutzap.zap-pending {
color: var(--muted-color);
}
.interaction-item.nutzap.zap-pending .interaction-icon,
.interaction-item.nutzap.zap-pending .interaction-icon.active,
.interaction-item.nutzap.zap-pending .interaction-count {
color: var(--muted-color) !important;
transition: color 240ms linear !important;
}
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon,
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon.active,
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-count {
color: var(--accent-color) !important;
}
.interaction-item.nutzap.zap-success,
.interaction-item.nutzap.zap-success .interaction-icon,
.interaction-item.nutzap.zap-success .interaction-icon.active,
.interaction-item.nutzap.zap-success .interaction-count {
color: var(--accent-color) !important;
}
/* Dimmed (capability unavailable) interaction items */
.interaction-item.dimmed {
opacity: 0.45;
cursor: not-allowed;
}
.interaction-item.dimmed:hover {
color: var(--muted-color);
}
.interaction-item.dimmed:hover .interaction-icon {
color: var(--muted-color);
}
/* ************************************************************************* */
/* AUTHOR HEADER */
/* ************************************************************************* */

729
www/feed-old.html Normal file
View File

@@ -0,0 +1,729 @@
<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FEED</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<link rel="stylesheet" href="./css/dot-menu.css" />
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="./js/vendor/svg.min.js"></script>
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px;
overflow-y: auto;
overflow-anchor: none;
}
#divHint,
#divFeed,
#btnSeeMore {
width: 80%;
min-width: 300px;
max-width: 700px;
}
#divHint {
text-align: center;
color: var(--muted-color);
font-size: 80%;
margin-top: 5px;
}
#divFeed {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 10px;
}
#btnSeeMore {
display: none;
margin-bottom: 20px;
}
.divPostItem .post-composer-wrapper {
width: 100%;
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">FEED</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divHint">Loading follows…</div>
<div id="divFeed"></div>
<button id="btnSeeMore" class="btn">See More</button>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divFiles"></div>
<div id="divFeedSettings" class="sidenavSection">
<div class="sidenavSectionTitle">Feed</div>
<label class="sidenavRowToggle" for="chkAutoplayVideos">
<span>Autoplay videos</span>
<input id="chkAutoplayVideos" type="checkbox" />
</label>
</div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">loading...</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
disconnect,
getVersion,
queryCache,
ndkFetchEvents,
fetchCachedProfile,
storeProfile,
publishEvent,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
onUserSettings,
ensureMuteListLoaded,
isEventMuted,
addMute
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { initPostCards } from './js/post-interactions2.mjs';
import { mountComposer } from './js/post-composer.mjs';
const INITIAL_POSTS_LOAD = 10;
const SEE_MORE_INCREMENT = 20;
const MIN_BOOTSTRAP_POSTS = 10;
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
let currentPubkey = null;
let updateIntervalId = null;
let unsubscribeUserSettings = null;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
const posts = [];
const postIds = new Set();
const renderedPostIds = new Set();
let displayCount = INITIAL_POSTS_LOAD;
let followedPubkeys = [];
const feedPubkeys = new Set();
const divBody = document.getElementById('divBody');
const divSideNav = document.getElementById('divSideNav');
const divFooterRight = document.getElementById('divFooterRight');
const divFeed = document.getElementById('divFeed');
const divHint = document.getElementById('divHint');
const btnSeeMore = document.getElementById('btnSeeMore');
const chkAutoplayVideos = document.getElementById('chkAutoplayVideos');
let autoplayVideosEnabled = false;
let liveFeedSubscriptionKey = '';
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
let isBootstrappingFeed = false;
let hasBootstrappedFeed = false;
const bufferedFeedEvents = [];
function toNostrNoteRef(postId) {
try {
return window?.NostrTools?.nip19?.noteEncode
? window.NostrTools.nip19.noteEncode(postId)
: postId;
} catch (_) {
return postId;
}
}
function focusInlineComposer(hostEl, content = '') {
if (!hostEl) return false;
hostEl.innerText = content || '';
hostEl.focus();
document.dispatchEvent(new Event('selectionchange'));
hostEl.dispatchEvent(new Event('input', { bubbles: true }));
window.requestAnimationFrame(() => {
hostEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
return true;
}
function getOrCreateInlineComposer(postId, postEl) {
if (!postId || !postEl) return null;
const existing = inlineComposerInstances.get(postId);
if (existing?.hostEl?.isConnected && existing?.instance) {
return existing;
}
const hostEl = document.createElement('div');
hostEl.className = 'inlinePostComposerInput';
hostEl.dataset.inlineComposerFor = postId;
postEl.appendChild(hostEl);
const entry = {
hostEl,
tags: [],
instance: null
};
const instance = mountComposer(hostEl, {
currentPubkey,
followedProfiles: [],
showUploadIcon: true,
showPreview: true,
autoHideOnSubmit: true,
hideOnEscape: true,
onSubmit: async (content) => {
const text = String(content || '').trim();
if (!text) return;
await publishEvent({
kind: 1,
content: text,
tags: entry.tags,
created_at: Math.floor(Date.now() / 1000)
});
}
});
entry.instance = instance;
inlineComposerInstances.set(postId, entry);
return entry;
}
function handleComposerCommentIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const noteRef = toNostrNoteRef(postId);
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'mention'],
['p', postPubkey],
['q', postId]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, `nostr:${noteRef}`);
}
async function handleMuteIntent({ eventData }) {
const targetPubkey = String(eventData?.pubkey || '').trim();
if (!targetPubkey || targetPubkey === currentPubkey) return;
const ok = window.confirm(`Mute ${targetPubkey.slice(0, 8)}${targetPubkey.slice(-4)}?`);
if (!ok) return;
await addMute('p', targetPubkey, false);
for (let i = posts.length - 1; i >= 0; i -= 1) {
if (posts[i]?.pubkey === targetPubkey) {
postIds.delete(posts[i]?.id);
posts.splice(i, 1);
}
}
renderFeed();
}
const cards = initPostCards({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: handleComposerCommentIntent,
onQuoteIntent: handleComposerQuoteIntent,
onMuteIntent: handleMuteIntent
});
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
isNavOpen ? closeNav() : openNav();
}
function isOriginalPost(event) {
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
if (eTags.length === 0) return true;
return eTags.every(t => t[3] === 'mention');
}
function applyVideoAutoplaySetting(enabled) {
autoplayVideosEnabled = !!enabled;
if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled;
const videoEls = Array.from(divFeed.querySelectorAll('video'));
videoEls.forEach((videoEl) => {
videoEl.controls = true;
videoEl.playsInline = true;
videoEl.setAttribute('playsinline', '');
videoEl.preload = 'metadata';
if (autoplayVideosEnabled) {
videoEl.autoplay = true;
videoEl.muted = true;
videoEl.setAttribute('autoplay', '');
videoEl.setAttribute('muted', '');
const playPromise = videoEl.play?.();
if (playPromise?.catch) playPromise.catch(() => {});
} else {
videoEl.autoplay = false;
videoEl.muted = false;
videoEl.removeAttribute('autoplay');
videoEl.removeAttribute('muted');
}
});
}
function applySettings(settings) {
const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay;
applyVideoAutoplaySetting(nextAutoplayEnabled);
}
async function persistAutoplaySetting(enabled) {
try {
await patchUserSettings({
feed: {
videoAutoplay: !!enabled
}
});
} catch (error) {
console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error);
}
}
function upsertFeedPost(evt) {
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
if (isEventMuted(evt)) return false;
postIds.add(evt.id);
posts.push(evt);
return true;
}
function renderFeed() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const toShow = posts.slice(0, displayCount);
divFeed.innerHTML = '';
renderedPostIds.clear();
toShow.forEach((post) => {
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
divFeed.appendChild(postEl);
renderedPostIds.add(post.id);
});
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = followedPubkeys.length > 0
? `${posts.length} posts from ${followedPubkeys.length} follows`
: 'Follow users to populate your feed.';
divFooterRight.textContent = `${posts.length} posts`;
}
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
if (!Array.isArray(authors) || authors.length === 0) return;
const filters = { kinds: [1], authors, limit };
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
filters.since = since;
}
const cached = await queryCache(filters).catch(() => []);
cached.forEach((evt1) => {
upsertFeedPost(evt1);
});
if (renderAfterCache && posts.length > 0) {
renderFeed();
}
void ndkFetchEvents(filters).then((fresh) => {
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
upsertFeedPost(evt1);
});
if (posts.length > 0) {
renderFeed();
}
}).catch(() => {});
}
async function bootstrapFeedPosts(authors) {
if (!Array.isArray(authors) || authors.length === 0) {
hasBootstrappedFeed = true;
renderFeed();
return;
}
isBootstrappingFeed = true;
const now = Math.floor(Date.now() / 1000);
await Promise.allSettled(
FEED_BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
fetchFeedWindow(authors, {
since: now - windowSeconds,
limit: FEED_BOOTSTRAP_WINDOW_LIMIT,
renderAfterCache: true
})
)
);
if (posts.length < MIN_BOOTSTRAP_POSTS) {
await fetchFeedWindow(authors, {
since: 0,
limit: FEED_BOOTSTRAP_WINDOW_LIMIT * 2,
renderAfterCache: true
});
}
bufferedFeedEvents.forEach((evt1) => {
if (feedPubkeys.has(evt1.pubkey)) {
upsertFeedPost(evt1);
}
});
bufferedFeedEvents.length = 0;
isBootstrappingFeed = false;
hasBootstrappedFeed = true;
renderFeed();
}
function ensureLiveFeedSubscription() {
const authors = Array.from(feedPubkeys).filter(Boolean);
if (authors.length === 0) return;
const key = authors.slice().sort().join(',');
if (key === liveFeedSubscriptionKey) return;
const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0);
const now = Math.floor(Date.now() / 1000);
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
subscribe(
{ kinds: [1], authors, since },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
liveFeedSubscriptionKey = key;
console.log('[feed.html] Live feed subscription active', { authors: authors.length, since });
}
async function ingestContactList(evt) {
if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || [];
followedPubkeys = pTags.map(tag => tag[1]);
const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk));
newAuthors.forEach((pk) => feedPubkeys.add(pk));
ensureLiveFeedSubscription();
const allAuthors = Array.from(feedPubkeys);
if (!hasBootstrappedFeed) {
await bootstrapFeedPosts(allAuthors);
return;
}
if (newAuthors.length > 0) {
try {
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
renderFeed();
} catch (_) {
renderFeed();
}
} else {
renderFeed();
}
}
async function logout() {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
cards.destroy();
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
if (unsubscribeUserSettings) {
unsubscribeUserSettings();
unsubscribeUserSettings = null;
}
localStorage.clear();
sessionStorage.clear();
location.reload(true);
}
async function updateFooter() {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
} catch (_) {}
}
(async function main() {
try {
const versionInfo = await getVersion();
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
initHamburgerMenu();
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
document.getElementById('themeToggleButton')?.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
});
chkAutoplayVideos?.addEventListener('change', async () => {
const nextEnabled = !!chkAutoplayVideos.checked;
applyVideoAutoplaySetting(nextEnabled);
await persistAutoplaySetting(nextEnabled);
});
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await ensureMuteListLoaded();
try {
const settings = await getUserSettings();
applySettings(settings || {});
} catch (error) {
console.warn('[feed.html] Failed to load user settings:', error?.message || error);
applySettings({});
}
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
applySettings(settings || {});
});
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await updateFooter();
updateIntervalId = setInterval(updateFooter, 1000);
window.addEventListener('ndkRelayActivity', (e) => {
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
});
window.addEventListener('ndkEvent', async (e) => {
const evt = e.detail;
if (!evt) return;
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
await ingestContactList(evt);
return;
}
if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt)) {
if (isBootstrappingFeed) {
bufferedFeedEvents.push(evt);
return;
}
if (!upsertFeedPost(evt)) return;
renderFeed();
}
});
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []);
if (cachedContactEvents.length > 0) {
const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
await ingestContactList(latest);
}
if (feedPubkeys.size === 0) {
divHint.textContent = 'Follow users to populate your feed.';
}
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
}
})();
</script>
</body>
</html>

View File

@@ -87,9 +87,7 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
@@ -157,13 +155,16 @@
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
onUserSettings,
ensureMuteListLoaded,
isEventMuted,
addMute
addMute,
warmOutbox
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
@@ -281,18 +282,10 @@ import { initPostCards } from './js/post-interactions2.mjs';
return entry;
}
function handleComposerCommentIntent({ postId, postPubkey }) {
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
if (!postEl) return false;
const entry = getOrCreateInlineComposer(postId, postEl);
if (!entry) return false;
entry.tags = [
['e', postId, '', 'reply'],
['p', postPubkey]
];
if (entry.instance?.show) entry.instance.show();
return focusInlineComposer(entry.hostEl, '');
function handleComposerCommentIntent({ postId }) {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
}
function handleComposerQuoteIntent({ postId, postPubkey }) {
@@ -335,11 +328,14 @@ import { initPostCards } from './js/post-interactions2.mjs';
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetBalance,
walletGetMints,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -348,6 +344,12 @@ import { initPostCards } from './js/post-interactions2.mjs';
onMuteIntent: handleMuteIntent
});
// Disable inline comment rendering — the new feed shows posts only.
// Comments are viewed on the post-feed.html thread page (new tab).
if (typeof cards.setCommentsVisible === 'function') {
cards.setCommentsVisible(false);
}
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
@@ -554,6 +556,10 @@ import { initPostCards } from './js/post-interactions2.mjs';
const now = Math.floor(Date.now() / 1000);
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
// Fire-and-forget: NDK.subscribe already calls trackUsers internally,
// but doing it explicitly avoids relying on internal timing.
void warmOutbox(authors).catch(() => {});
subscribe(
{ kinds: [1], authors, since },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
@@ -576,11 +582,23 @@ import { initPostCards } from './js/post-interactions2.mjs';
const allAuthors = Array.from(feedPubkeys);
if (!hasBootstrappedFeed) {
// Pre-warm the outbox tracker so NDK knows each follow's write relays
// before the short-lived bootstrap fetchEvents calls fire.
try {
await warmOutbox(followedPubkeys);
} catch (e) {
console.warn('[feed.html] warmOutbox failed (non-blocking):', e?.message || e);
}
await bootstrapFeedPosts(allAuthors);
return;
}
if (newAuthors.length > 0) {
try {
await warmOutbox(newAuthors);
} catch (e) {
console.warn('[feed.html] warmOutbox for new authors failed (non-blocking):', e?.message || e);
}
try {
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
renderFeed();
@@ -639,12 +657,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
@@ -720,7 +732,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
} catch (error) {
console.error('[feed.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
}
})();
</script>

View File

@@ -433,6 +433,37 @@ function handleWorkerMessage(event) {
pending.resolve(message.events || []);
}
}
} else if (message.type === 'warmOutboxResult') {
// Handle warmOutbox response — best-effort pre-warm; always resolves.
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve({
ok: !!message.ok,
count: message.count || 0,
skipped: !!message.skipped,
error: message.error || null
});
}
} else if (message.type === 'setRelayEventLoggingResult') {
// Handle setRelayEventLogging response — fire-and-forget, but resolve
// the promise if the caller chose to await it.
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve({
ok: !!message.ok,
enabled: !!message.enabled,
error: message.error || null
});
}
} else if (message.type === 'getDiscoveredRelaysResult') {
// Handle getDiscoveredRelays response
const pending = pendingRequests.get(message.requestId);
if (pending) {
pendingRequests.delete(message.requestId);
pending.resolve(message.relays || []);
}
} else if (message.type === 'fetchCachedProfileResult') {
// Handle cached profile response
const pending = pendingRequests.get(message.requestId);
@@ -763,6 +794,128 @@ export async function ndkFetchEvents(filters) {
});
}
/**
* Pre-warm the NDK outbox tracker for a set of follow pubkeys.
*
* This proactively resolves each follow's kind 10002 relay list via
* ndk.outboxTracker.trackUsers so that subsequent short-lived fetchEvents
* subscriptions route to the correct write relays instead of racing the
* tracker. Pre-warming is best-effort: the returned promise always resolves
* (never rejects). On timeout it resolves with { ok: false, timedOut: true }.
*
* @param {string[]} pubkeys - Array of hex pubkeys to pre-warm.
* @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, timedOut?: boolean, error?: string}>}
*/
export async function warmOutbox(pubkeys) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
const list = Array.isArray(pubkeys) ? pubkeys.filter(Boolean) : [];
if (list.length === 0) {
return { ok: true, count: 0, skipped: true };
}
return new Promise((resolve) => {
requestCounter++;
const requestId = `warmOutbox_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject: resolve });
// Best-effort: resolve (not reject) on timeout.
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve({ ok: false, timedOut: true });
}
}, 15000);
ndkWorker.port.postMessage({
type: 'warmOutbox',
requestId: requestId,
pubkeys: list
});
});
}
/**
* Toggle relay event logging in the worker.
*
* When enabled, the worker broadcasts relayEvent messages to all ports and
* persists relay events to IndexedDB. When disabled (default), both are
* skipped to avoid cross-page broadcast spam and IndexedDB write contention.
* trackRelayRead/trackRelayWrite (relayActivity events) are not affected.
*
* Fire-and-forget: the returned promise resolves when the worker acknowledges,
* but callers may ignore it.
*
* @param {boolean} enabled - Whether relay event logging should be on.
* @returns {Promise<{ok: boolean, enabled: boolean, error?: string}>}
*/
export function setRelayEventLogging(enabled) {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve) => {
requestCounter++;
const requestId = `setRelayEventLogging_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject: resolve });
// Fire-and-forget timeout — resolve gracefully if the worker never
// acknowledges (it always should, but be safe).
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve({ ok: false, enabled: !!enabled, error: 'setRelayEventLogging timeout' });
}
}, 5000);
ndkWorker.port.postMessage({
type: 'setRelayEventLogging',
requestId: requestId,
enabled: !!enabled
});
});
}
/**
* Get the set of "discovered" relays currently in the NDK pool that are NOT
* part of the user's own kind 10002 relay list. These are temporary outbox
* relays NDK connected to in order to fetch events from followed authors who
* write to relays the user does not subscribe to.
*
* Each entry is { url, status, connected, servingPubkeys: string[] }.
*
* @returns {Promise<Array<{url: string, status: number, connected: boolean, servingPubkeys: string[]}>>}
*/
export async function getDiscoveredRelays() {
if (!ndkWorker) {
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
}
return new Promise((resolve) => {
requestCounter++;
const requestId = `getDiscoveredRelays_${Date.now()}_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject: resolve });
// Resolve with [] on timeout — this is a read-only diagnostic call.
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
resolve([]);
}
}, 5000);
ndkWorker.port.postMessage({
type: 'getDiscoveredRelays',
requestId: requestId
});
});
}
/**
* Fetch events from all relays in parallel
* Returns events grouped by relay URL

View File

@@ -9,8 +9,10 @@ import { createProfileCache } from './profile-cache.mjs';
import { mountDotMenu } from './dot-menu.mjs';
import {
promptZapDetails,
promptNutzapDetails,
prepareZapInvoiceForEvent,
resolveNutzapSpecForPubkey,
resolveZapCapabilities,
extractZapAmount as extractZapAmountFromReceipt,
extractZapComment as extractZapCommentFromReceipt
} from './zaps.mjs';
@@ -879,12 +881,13 @@ const ICON_LABELS = {
like: { label: 'Like', activeLabel: 'Like' },
comment: { label: 'Comment', activeLabel: 'Comment' },
quote: { label: 'Quote', activeLabel: 'Quote' },
zap: { label: 'Zap', activeLabel: 'Zap' }
zap: { label: 'Zap', activeLabel: 'Zap' },
nutzap: { label: 'Nutzap', activeLabel: 'Nutzap' }
};
/**
* Generate HTML for interaction label text
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap'
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap', 'nutzap'
* @param {boolean} active - Whether the label is in active/selected state
* @returns {string} HTML string with label text
*/
@@ -957,6 +960,8 @@ let publishEventFn = null;
let walletPayInvoiceFn = null;
let walletSendNutzapFn = null;
let walletFetchMintListFn = null;
let walletGetMintsFn = null;
let walletGetBalanceFn = null;
let getRelayDataFn = null;
let getUserSettingsFn = null;
let patchUserSettingsFn = null;
@@ -1049,7 +1054,7 @@ export function renderFooterRow(eventId, eventData, options = {}) {
onClick: () => handleQuoteClick(eventId, eventData.pubkey, currentPubkey, quoteItem)
});
// Zap button
// Zap button (Lightning only)
const zapItem = createInteractionItem({
type: 'zap',
count: state.zapTotal,
@@ -1059,8 +1064,16 @@ export function renderFooterRow(eventId, eventData, options = {}) {
});
updateZapCountDisplay(zapItem, state);
const nutzapCountEl = createNutzapCountDisplay(state);
// Nutzap button (Cashu ecash)
const nutzapItem = createInteractionItem({
type: 'nutzap',
count: state.nutzapTotal,
active: false,
title: 'Nutzap',
onClick: () => handleNutzapClick(eventId, eventData.pubkey, currentPubkey, nutzapItem)
});
updateNutzapButtonCount(nutzapItem, state);
const midControls = document.createElement('div');
midControls.className = 'divPostMidControls';
@@ -1069,7 +1082,7 @@ export function renderFooterRow(eventId, eventData, options = {}) {
midControls.appendChild(quoteItem);
container.appendChild(zapItem);
container.appendChild(nutzapCountEl);
container.appendChild(nutzapItem);
container.appendChild(midControls);
// Time as the final item in the same row
@@ -1079,7 +1092,11 @@ export function renderFooterRow(eventId, eventData, options = {}) {
container.appendChild(timeEl);
footerRow.appendChild(container);
// Async, non-blocking capability dimming.
// Resolved after the bar is in the DOM so feed rendering is never blocked.
setTimeout(() => applyZapCapabilityDimming(eventId, eventData.pubkey, zapItem, nutzapItem), 0);
return footerRow;
}
@@ -1126,7 +1143,7 @@ export function renderInteractionBar(postId, postData, options = {}) {
onClick: () => handleQuoteClick(postId, postData.pubkey, currentPubkey, quoteItem)
});
// Zap button
// Zap button (Lightning only)
const zapItem = createInteractionItem({
type: 'zap',
count: state.zapTotal,
@@ -1137,14 +1154,27 @@ export function renderInteractionBar(postId, postData, options = {}) {
});
updateZapCountDisplay(zapItem, state);
const nutzapCountEl = createNutzapCountDisplay(state);
// Nutzap button (Cashu ecash)
const nutzapItem = createInteractionItem({
type: 'nutzap',
count: state.nutzapTotal,
active: false,
showCount: state.nutzapTotal > 0,
title: 'Nutzap',
onClick: () => handleNutzapClick(postId, postData.pubkey, currentPubkey, nutzapItem)
});
updateNutzapButtonCount(nutzapItem, state);
container.appendChild(zapItem);
container.appendChild(nutzapCountEl);
container.appendChild(nutzapItem);
container.appendChild(likeItem);
container.appendChild(commentItem);
container.appendChild(quoteItem);
// Async, non-blocking capability dimming.
// Resolved after the bar is in the DOM so feed rendering is never blocked.
setTimeout(() => applyZapCapabilityDimming(postId, postData.pubkey, zapItem, nutzapItem), 0);
return container;
}
@@ -1194,10 +1224,10 @@ export function updateInteractionBar(postId, state) {
updateZapCountDisplay(zapItem, state);
}
// Update nutzap count display
const nutzapCountEl = bar.querySelector('.nutzap-count-display');
if (nutzapCountEl) {
updateNutzapCountDisplay(nutzapCountEl, state);
// Update nutzap button count
const nutzapItem = bar.querySelector('.interaction-item.nutzap');
if (nutzapItem) {
updateNutzapButtonCount(nutzapItem, state);
}
}
@@ -1272,34 +1302,80 @@ function updateZapCountDisplay(itemEl, state = {}) {
countEl.textContent = formatCount(zapTotal);
}
function createNutzapCountDisplay(state = {}) {
const el = document.createElement('div');
el.className = 'nutzap-count-display';
el.title = 'Nutzaps';
const emojiEl = document.createElement('span');
emojiEl.className = 'nutzap-emoji';
emojiEl.textContent = '🥜';
const countEl = document.createElement('span');
countEl.className = 'nutzap-count-value';
el.appendChild(emojiEl);
el.appendChild(countEl);
updateNutzapCountDisplay(el, state);
return el;
}
function updateNutzapCountDisplay(el, state = {}) {
const countEl = el?.querySelector?.('.nutzap-count-value');
/**
* Update the count display on the Nutzap interaction button.
* Mirrors updateZapCountDisplay but for the nutzap button.
* @param {HTMLElement} itemEl - The nutzap interaction item element
* @param {Object} state - The interaction state
*/
function updateNutzapButtonCount(itemEl, state = {}) {
const countEl = itemEl?.querySelector?.('.interaction-count');
if (!countEl) return;
const nutzapTotal = Math.max(0, Number(state?.nutzapTotal || 0));
const hasCount = nutzapTotal > 0;
countEl.textContent = formatCount(nutzapTotal);
el.classList.toggle('has-count', hasCount);
}
// =============================================================================
// ZAP CAPABILITY DIMMING
// =============================================================================
//
// Instead of separate capability badges, the Zap and Nutzap buttons themselves
// indicate capability via dimming:
// - Zap button dimmed when recipient has no lud16 Lightning address
// - Nutzap button dimmed when recipient has no shared mints for nutzaps
//
// Dimming is applied asynchronously after the bar is in the DOM so feed
// rendering is never blocked. Colors come exclusively from CSS variables.
/**
* Dim the Zap and/or Nutzap buttons based on the recipient's zap capabilities.
* Resolves capabilities async and applies a `.dimmed` class + tooltip to
* buttons whose rail is unavailable. Non-throwing.
*
* @param {string} postId - the event id (used for logging only)
* @param {string} pubkey - the post author's pubkey
* @param {HTMLElement} zapItem - the Zap (Lightning) interaction item
* @param {HTMLElement} nutzapItem - the Nutzap interaction item
*/
function applyZapCapabilityDimming(postId, pubkey, zapItem, nutzapItem) {
if (!pubkey) return;
if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') {
return; // nothing to resolve with
}
if (!zapItem && !nutzapItem) return;
resolveZapCapabilities(pubkey, {
fetchProfile,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][zap-caps]'
}).then((caps) => {
// Elements may have been detached from the DOM by the time this resolves.
if (zapItem && zapItem.isConnected) {
if (!caps.canLightning) {
zapItem.classList.add('dimmed');
zapItem.title = 'No Lightning address';
} else {
zapItem.classList.remove('dimmed');
zapItem.title = caps.lud16 ? `Lightning: ${caps.lud16}` : 'Zap';
}
}
if (nutzapItem && nutzapItem.isConnected) {
if (!caps.canNutzap) {
nutzapItem.classList.add('dimmed');
nutzapItem.title = caps.hasMintList
? 'No shared mints for nutzap'
: 'No shared mints for nutzap';
} else {
nutzapItem.classList.remove('dimmed');
nutzapItem.title = 'Nutzap';
}
}
}).catch((error) => {
console.warn('[post-interactions][zap-caps] dimming failed', error?.message || error);
});
}
// =============================================================================
@@ -1323,6 +1399,66 @@ function isZapTimeoutError(error) {
return msg.includes('timeout');
}
/**
* Classify a zap/melt payment error into a user-friendly message.
* Hides raw mint API errors while still being informative.
* @param {Error|*} error
* @returns {string}
*/
function friendlyZapErrorMessage(error) {
const raw = String(error?.message || error || '').trim();
const msg = raw.toLowerCase();
if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) {
return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.';
}
if (msg.includes('timeout')) {
return 'Payment timed out. The mint may be slow or unresponsive.';
}
if (msg.includes('insufficient') || msg.includes('balance')) {
return 'Insufficient Cashu balance for this payment.';
}
return raw || 'Zap failed.';
}
/**
* Fetch the current user's Cashu balance in sats.
* Returns null when the wallet function is unavailable or the fetch fails.
* @returns {Promise<number|null>}
*/
async function fetchWalletBalanceSats() {
if (typeof walletGetBalanceFn !== 'function') return null;
try {
const result = await walletGetBalanceFn();
const balance = Number(result?.balance ?? result?.sats ?? result);
if (Number.isFinite(balance) && balance >= 0) {
return Math.floor(balance);
}
} catch (error) {
console.warn('[post-interactions][zap] fetchWalletBalanceSats:failed', error?.message || error);
}
return null;
}
/**
* Refresh the wallet balance display after a zap.
* Re-fetches the balance and dispatches an `ndkWalletBalance` event so any
* footer/dialog balance displays can update. Non-throwing.
* @returns {Promise<void>}
*/
async function refreshWalletBalanceDisplay() {
const balanceSats = await fetchWalletBalanceSats();
if (balanceSats === null) return;
try {
window.dispatchEvent(new CustomEvent('ndkWalletBalance', {
detail: { balanceSats, source: 'post-interactions-zap' }
}));
} catch (error) {
console.warn('[post-interactions][zap] refreshWalletBalanceDisplay:dispatch-failed', error?.message || error);
}
}
// =============================================================================
// INTERACTION HANDLERS
// =============================================================================
@@ -1558,6 +1694,18 @@ async function saveZapDefaultsToSettings({ amountSats, comment }) {
});
}
/**
* Handle Zap (Lightning) button click.
*
* Sends a Lightning zap via Cashu melt: create a LN invoice from the
* recipient's lud16, then pay it via walletPayInvoiceFn. No rail selector —
* nutzaps have their own button and handler.
*
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The interaction item element
*/
async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
if (!postId || !postPubkey || !itemEl) return;
if (itemEl.dataset.busy === '1') return;
@@ -1583,34 +1731,31 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
let paymentSucceeded = false;
try {
if (typeof walletPayInvoiceFn !== 'function' && typeof walletSendNutzapFn !== 'function') {
throw new Error('Cashu wallet payer is not configured on this page');
if (typeof walletPayInvoiceFn !== 'function') {
throw new Error('Lightning zap unavailable (walletPayInvoice not configured)');
}
let nutzapSpec = null;
if (typeof walletFetchMintListFn === 'function') {
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
fetchMintList: walletFetchMintListFn,
logPrefix: '[post-interactions][nutzap]'
});
} catch (_nutzapDiscoveryError) {
nutzapSpec = null;
const balanceResult = await walletGetBalanceFn();
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
if (Number.isFinite(balance) && balance >= 0) {
balanceSats = Math.floor(balance);
}
} catch (balanceError) {
console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError);
}
}
console.log('[post-interactions][zap] handleZapClick:nutzap-support', {
recipient: String(postPubkey || '').slice(0, 8) + '…',
hasNutzapSupport: Boolean(nutzapSpec?.mints?.length),
mintCount: Array.isArray(nutzapSpec?.mints) ? nutzapSpec.mints.length : 0
});
const zapDefaults = await getZapDefaultsFromSettings();
const details = await promptZapDetails({
defaultAmountSats: zapDefaults.amountSats,
defaultComment: zapDefaults.comment,
defaultShouldZap: true,
onSaveDefault: saveZapDefaultsToSettings
onSaveDefault: saveZapDefaultsToSettings,
balanceSats
});
if (!details) {
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
@@ -1652,40 +1797,59 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
return;
}
if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') {
if (countEl) countEl.textContent = 'send…';
console.log('[post-interactions][zap] handleZapClick:sending-nutzap');
const nutzapResult = await walletSendNutzapFn({
amount: details.amountSats,
memo: details.comment,
targetPubkey: postPubkey,
eventId: postId,
recipientMints: nutzapSpec.mints,
recipientP2pk: nutzapSpec.p2pk,
recipientRelays: nutzapSpec.relays
});
console.log('[post-interactions][zap] handleZapClick:nutzap-ok', nutzapResult || {});
} else {
if (typeof walletPayInvoiceFn !== 'function') {
throw new Error('LN zap fallback unavailable (walletPayInvoice missing)');
// --- Pre-flight balance check (Phase 5) --------------------------------
// A final safety net right before sending, in case the user ignored the
// dialog warning or the balance changed between dialog and send.
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
const preflightBalance = await fetchWalletBalanceSats();
if (preflightBalance !== null) {
if (preflightBalance < zapAmount) {
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', {
balance: preflightBalance, needed: zapAmount
});
if (countEl) {
countEl.textContent = 'low';
countEl.title = msg;
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, 2200);
}
window.alert(msg);
return;
}
const { invoice } = await prepareZapInvoiceForEvent({
eventId: postId,
recipientPubkey: postPubkey,
amountSats: details.amountSats,
comment: details.comment,
fetchProfile,
getRelayData: getRelayDataFn,
logPrefix: '[post-interactions][zap]'
});
if (countEl) countEl.textContent = 'pay…';
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
const paymentResult = await walletPayInvoiceFn(invoice);
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
// "Barely sufficient" warning: remaining balance would be less than
// 10% of the current balance.
const remaining = preflightBalance - zapAmount;
const tenPercent = Math.floor(preflightBalance * 0.10);
if (remaining > 0 && remaining < tenPercent) {
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
const proceed = window.confirm(confirmMsg);
if (!proceed) {
console.log('[post-interactions][zap] handleZapClick:declined-low-balance');
return;
}
}
}
// --- Create the LN invoice and pay it via Cashu melt -------------------
const { invoice } = await prepareZapInvoiceForEvent({
eventId: postId,
recipientPubkey: postPubkey,
amountSats: details.amountSats,
comment: details.comment,
fetchProfile,
getRelayData: getRelayDataFn,
logPrefix: '[post-interactions][zap]'
});
if (countEl) countEl.textContent = 'pay…';
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
const paymentResult = await walletPayInvoiceFn(invoice);
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
itemEl.classList.add('zap-success');
@@ -1702,15 +1866,28 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
itemEl.classList.remove('zap-success');
updateZapCountDisplay(itemEl, state);
}, 1800);
// --- Post-zap balance refresh (Phase 5) --------------------------------
// Refresh the wallet balance display so the user sees their updated
// balance after a successful zap. Non-blocking — failures are logged
// but never surface to the user since the zap itself succeeded.
refreshWalletBalanceDisplay().catch((refreshError) => {
console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError);
});
} catch (error) {
const timeoutFailure = isZapTimeoutError(error);
const friendlyMessage = friendlyZapErrorMessage(error);
console.error('[post-interactions][zap] handleZapClick:failed', error);
if (countEl) {
countEl.textContent = timeoutFailure ? 'check' : 'fail';
countEl.title = friendlyMessage;
setTimeout(() => {
updateZapCountDisplay(itemEl, getPostState(postId));
}, timeoutFailure ? 2800 : 2200);
}
// Surface a user-friendly error message. Avoids exposing raw mint API
// errors while still being informative.
window.alert(friendlyMessage);
itemEl.classList.remove('zap-success');
} finally {
clearInterval(zapPulseInterval);
@@ -1728,6 +1905,255 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
}
}
/**
* Handle Nutzap (Cashu ecash) button click.
*
* Checks the recipient's kind 10019 for shared mints, shows a nutzap-specific
* dialog (with the recipient's accepted mints), and sends ecash via
* walletSendNutzapFn. Mirrors handleZapClick's UI feedback (pulse animation,
* count update, balance check).
*
* @param {string} postId - Post ID
* @param {string} postPubkey - Post author's pubkey
* @param {string} currentPubkey - Current user's pubkey
* @param {HTMLElement} itemEl - The nutzap interaction item element
*/
async function handleNutzapClick(postId, postPubkey, currentPubkey, itemEl) {
if (!postId || !postPubkey || !itemEl) return;
if (itemEl.dataset.busy === '1') return;
const iconContainer = itemEl.querySelector('.interaction-icon-container');
const countEl = itemEl.querySelector('.interaction-count');
console.log('[post-interactions][nutzap] handleNutzapClick:start', {
postId: String(postId || '').slice(0, 8) + '…',
recipient: String(postPubkey || '').slice(0, 8) + '…'
});
itemEl.dataset.busy = '1';
itemEl.classList.add('zap-pending');
updateIconState(iconContainer, 'nutzap', true);
let pendingAccent = false;
const zapPulseInterval = setInterval(() => {
pendingAccent = !pendingAccent;
itemEl.classList.toggle('zap-pending-accent', pendingAccent);
}, 500);
let paymentSucceeded = false;
try {
if (typeof walletSendNutzapFn !== 'function') {
throw new Error('Nutzap unavailable (walletSendNutzap not configured)');
}
// --- Resolve zap capabilities to confirm nutzap is possible -------------
let zapCaps = null;
try {
zapCaps = await resolveZapCapabilities(postPubkey, {
fetchProfile,
fetchMintList: walletFetchMintListFn || undefined,
getSenderMints: walletGetMintsFn || undefined,
logPrefix: '[post-interactions][nutzap-caps]'
});
} catch (capsError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:caps-failed', capsError?.message || capsError);
}
const canNutzap = Boolean(zapCaps?.canNutzap);
const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : [];
const recipientMints = Array.isArray(zapCaps?.nutzapMints) ? zapCaps.nutzapMints : [];
console.log('[post-interactions][nutzap] handleNutzapClick:caps', {
recipient: String(postPubkey || '').slice(0, 8) + '…',
canNutzap,
sharedMints: sharedMints.length,
recipientMints: recipientMints.length
});
if (!canNutzap || sharedMints.length === 0) {
window.alert('Recipient cannot receive nutzaps (no shared mints)');
console.log('[post-interactions][nutzap] handleNutzapClick:no-shared-mints');
return;
}
// --- Resolve the nutzap spec (mints, p2pk, relays) for the send --------
let nutzapSpec = null;
try {
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
fetchMintList: walletFetchMintListFn,
logPrefix: '[post-interactions][nutzap]'
});
} catch (nutzapDiscoveryError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:spec-failed', nutzapDiscoveryError?.message || nutzapDiscoveryError);
window.alert('Recipient cannot receive nutzaps (no shared mints)');
return;
}
if (!nutzapSpec?.mints?.length) {
window.alert('Recipient cannot receive nutzaps (no shared mints)');
return;
}
// --- Fetch the user's Cashu balance for the dialog ----------------------
let balanceSats = null;
if (typeof walletGetBalanceFn === 'function') {
try {
const balanceResult = await walletGetBalanceFn();
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
if (Number.isFinite(balance) && balance >= 0) {
balanceSats = Math.floor(balance);
}
} catch (balanceError) {
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-failed', balanceError?.message || balanceError);
}
}
const zapDefaults = await getZapDefaultsFromSettings();
const details = await promptNutzapDetails({
defaultAmountSats: zapDefaults.amountSats,
defaultComment: zapDefaults.comment,
defaultShouldZap: true,
onSaveDefault: saveZapDefaultsToSettings,
balanceSats,
recipientMints: nutzapSpec.mints,
sharedMints
});
if (!details) {
console.log('[post-interactions][nutzap] handleNutzapClick:cancelled-by-user');
return;
}
console.log('[post-interactions][nutzap] handleNutzapClick:details', {
amountSats: details.amountSats,
hasComment: Boolean(String(details.comment || '').trim()),
shouldZap: Boolean(details.shouldZap)
});
const state = getPostState(postId);
if (!state.userLiked && currentPubkey) {
const interactionBar = itemEl.closest('.divPostInteractions');
const likeItem = interactionBar?.querySelector('.interaction-item.like');
if (likeItem) {
try {
await handleLikeClick(postId, postPubkey, currentPubkey, likeItem);
console.log('[post-interactions][nutzap] handleNutzapClick:auto-like:ok');
} catch (likeError) {
console.error('[post-interactions][nutzap] handleNutzapClick:auto-like:failed', likeError);
}
}
}
const shouldZap = details.shouldZap !== false;
if (!shouldZap) {
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
if (countEl) {
countEl.textContent = '';
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, 150);
}
return;
}
// --- Pre-flight balance check (Phase 5) --------------------------------
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
const preflightBalance = await fetchWalletBalanceSats();
if (preflightBalance !== null) {
if (preflightBalance < zapAmount) {
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
console.warn('[post-interactions][nutzap] handleNutzapClick:insufficient-balance', {
balance: preflightBalance, needed: zapAmount
});
if (countEl) {
countEl.textContent = 'low';
countEl.title = msg;
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, 2200);
}
window.alert(msg);
return;
}
const remaining = preflightBalance - zapAmount;
const tenPercent = Math.floor(preflightBalance * 0.10);
if (remaining > 0 && remaining < tenPercent) {
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
const proceed = window.confirm(confirmMsg);
if (!proceed) {
console.log('[post-interactions][nutzap] handleNutzapClick:declined-low-balance');
return;
}
}
}
// --- Send the nutzap (Cashu ecash) -------------------------------------
if (countEl) countEl.textContent = 'send…';
console.log('[post-interactions][nutzap] handleNutzapClick:sending-nutzap');
const nutzapResult = await walletSendNutzapFn({
amount: details.amountSats,
memo: details.comment,
targetPubkey: postPubkey,
eventId: postId,
recipientMints: nutzapSpec.mints,
recipientP2pk: nutzapSpec.p2pk,
recipientRelays: nutzapSpec.relays
});
console.log('[post-interactions][nutzap] handleNutzapClick:nutzap-ok', nutzapResult || {});
paymentSucceeded = true;
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
itemEl.classList.add('zap-success');
if (countEl) countEl.textContent = 'sent';
const sentAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
if (sentAmount > 0) {
state.nutzapTotal = Math.max(0, Number(state.nutzapTotal || 0)) + sentAmount;
updateNutzapButtonCount(itemEl, state);
}
setTimeout(() => {
itemEl.classList.remove('zap-success');
updateNutzapButtonCount(itemEl, state);
}, 1800);
// --- Post-nutzap balance refresh ---------------------------------------
refreshWalletBalanceDisplay().catch((refreshError) => {
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-refresh-failed', refreshError?.message || refreshError);
});
} catch (error) {
const timeoutFailure = isZapTimeoutError(error);
const friendlyMessage = friendlyZapErrorMessage(error);
console.error('[post-interactions][nutzap] handleNutzapClick:failed', error);
if (countEl) {
countEl.textContent = timeoutFailure ? 'check' : 'fail';
countEl.title = friendlyMessage;
setTimeout(() => {
updateNutzapButtonCount(itemEl, getPostState(postId));
}, timeoutFailure ? 2800 : 2200);
}
window.alert(friendlyMessage);
itemEl.classList.remove('zap-success');
} finally {
clearInterval(zapPulseInterval);
itemEl.dataset.busy = '0';
if (!paymentSucceeded) {
itemEl.classList.remove('active', 'zap-pending', 'zap-pending-accent', 'zap-success');
updateIconState(iconContainer, 'nutzap', false);
} else {
itemEl.classList.remove('active', 'zap-pending-accent');
updateIconState(iconContainer, 'nutzap', true);
}
console.log('[post-interactions][nutzap] handleNutzapClick:finish');
}
}
/**
* Update the count display for an interaction item
* @param {HTMLElement} itemEl - The interaction item element
@@ -2308,6 +2734,8 @@ export function initInteractions(ndkFunctions = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -2325,6 +2753,8 @@ export function initInteractions(ndkFunctions = {}) {
walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null;
walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null;
walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null;
walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null;
walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null;
getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null;
getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null;
patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null;

View File

@@ -14,6 +14,8 @@ export function initPostCards(deps = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS,
getUserSettings,
@@ -34,6 +36,8 @@ export function initPostCards(deps = {}) {
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
@@ -65,7 +69,9 @@ export function initPostCards(deps = {}) {
function handleUpdate(postId, state, event, currentPubkey) {
interactions.updateInteractionBar(postId, state);
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId)) {
// Only render inline comments if comments are visible (the new feed
// sets commentsVisible=false to avoid inline reply rendering).
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId) && interactions.getCommentsVisible?.()) {
interactions.addCommentToPost(postId, {
id: event.id,
pubkey: event.pubkey,

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.24",
"VERSION_NUMBER": "0.7.24",
"BUILD_DATE": "2026-06-25T18:05:33.223Z"
"VERSION": "v0.7.45",
"VERSION_NUMBER": "0.7.45",
"BUILD_DATE": "2026-06-26T14:08:13.927Z"
}

View File

@@ -249,6 +249,37 @@ function closeActiveZapModal(result, resolver) {
resolver(result);
}
/**
* Escape a string for safe insertion into HTML text/attribute content.
* @param {string} value
* @returns {string}
*/
const HTML_ESCAPE_RE = /[&<>"']/g;
function escapeHtml(value) {
const amp = '&' + 'amp;';
const lt = '&' + 'lt;';
const gt = '&' + 'gt;';
const quot = '&' + 'quot;';
const apos = '&' + '#39;';
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
}
/**
* Lightning-only zap details dialog.
*
* Shows amount input, preset amounts, Cashu balance, and an insufficient-balance
* warning. No rail selector — Lightning is the only rail here. Nutzaps have
* their own dialog (`promptNutzapDetails`) and their own button.
*
* @param {Object} options
* @param {number} [options.defaultAmountSats=21]
* @param {string} [options.defaultComment='']
* @param {boolean} [options.defaultShouldZap=true]
* @param {Function} [options.onSaveDefault]
* @param {number|null} [options.balanceSats=null]
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
*/
export function promptZapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
@@ -258,7 +289,8 @@ export function promptZapDetails(options = {}) {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null
onSaveDefault = null,
balanceSats = null
} = options;
return new Promise((resolve) => {
@@ -268,11 +300,16 @@ export function promptZapDetails(options = {}) {
: 21;
const normalizedDefaultComment = String(defaultComment || '').trim();
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
<div class="zap-selector-title">Send Zap</div>
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send Lightning zap">
<div class="zap-selector-title">⚡ Lightning Zap</div>
${balanceDisplay}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
@@ -284,9 +321,10 @@ export function promptZapDetails(options = {}) {
Amount (sats)
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${normalizedDefaultComment.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</textarea>
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
</label>
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
@@ -307,6 +345,7 @@ export function promptZapDetails(options = {}) {
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
@@ -315,11 +354,24 @@ export function promptZapDetails(options = {}) {
return { amountSats, comment, shouldZap };
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
const balance = Number(balanceSats);
const insufficient = Number.isFinite(balance)
&& balance >= 0
&& Number.isFinite(amountSats)
&& amountSats > balance;
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
warningEl.style.display = insufficient ? '' : 'none';
};
const selectPreset = (button) => {
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
updateBalanceWarning();
};
presetButtons.forEach((button) => {
@@ -328,6 +380,7 @@ export function promptZapDetails(options = {}) {
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
updateBalanceWarning();
});
saveDefaultBtn?.addEventListener('click', async () => {
@@ -390,6 +443,222 @@ export function promptZapDetails(options = {}) {
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
updateBalanceWarning();
amountEl?.focus();
});
}
/**
* Nutzap (Cashu ecash) details dialog.
*
* Shows amount input, preset amounts, Cashu balance, the recipient's accepted
* mints (from kind 10019), and which shared mint will be used to send the
* ecash. Returns the user's choices or null if cancelled.
*
* @param {Object} options
* @param {number} [options.defaultAmountSats=21]
* @param {string} [options.defaultComment='']
* @param {boolean} [options.defaultShouldZap=true]
* @param {Function} [options.onSaveDefault]
* @param {number|null} [options.balanceSats=null]
* @param {string[]} [options.recipientMints=[]] - all mints on recipient's kind 10019
* @param {string[]} [options.sharedMints=[]] - mints shared with sender's wallet
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
*/
export function promptNutzapDetails(options = {}) {
if (activeZapModalEl) {
return Promise.resolve(null);
}
const {
defaultAmountSats = 21,
defaultComment = '',
defaultShouldZap = true,
onSaveDefault = null,
balanceSats = null,
recipientMints = [],
sharedMints = []
} = options;
const normalizedRecipientMints = Array.isArray(recipientMints)
? recipientMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const normalizedSharedMints = Array.isArray(sharedMints)
? sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
const primarySharedMint = normalizedSharedMints[0] || '';
return new Promise((resolve) => {
const initialAmount = Number(defaultAmountSats);
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
? Math.floor(initialAmount)
: 21;
const normalizedDefaultComment = String(defaultComment || '').trim();
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
: '';
const sharedMintInfoHtml = primarySharedMint
? `<div class="zap-selector-mint-info">Will send via ${escapeHtml(primarySharedMint)}</div>`
: '';
const recipientMintsListHtml = normalizedRecipientMints.length > 0
? `<div class="zap-selector-mint-info">
<div>Recipient accepts mints:</div>
<ul class="zap-selector-mint-list">
${normalizedRecipientMints.map((m) => {
const isShared = normalizedSharedMints.some(
(s) => s.replace(/\/+$/, '').toLowerCase() === m.replace(/\/+$/, '').toLowerCase()
);
return `<li class="zap-selector-mint-list-item${isShared ? ' shared' : ''}">${escapeHtml(m)}${isShared ? ' ✓' : ''}</li>`;
}).join('')}
</ul>
</div>`
: '';
const overlay = document.createElement('div');
overlay.className = 'zap-selector-overlay';
overlay.innerHTML = `
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send nutzap">
<div class="zap-selector-title">🥜 Nutzap</div>
${balanceDisplay}
<div class="zap-selector-presets">
<button type="button" class="zap-preset" data-amount="21">🥜21</button>
<button type="button" class="zap-preset" data-amount="100">🥜100</button>
<button type="button" class="zap-preset" data-amount="500">🥜500</button>
<button type="button" class="zap-preset" data-amount="1000">🥜1K</button>
<button type="button" class="zap-preset" data-amount="5000">🥜5K</button>
</div>
<label class="zap-selector-label">
Amount (sats)
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
</label>
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
${sharedMintInfoHtml}
${recipientMintsListHtml}
<label class="zap-selector-label">
Comment (optional)
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post 🥜">${escapeHtml(normalizedDefaultComment)}</textarea>
</label>
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
Send nutzap
</label>
<div class="zap-selector-actions">
<button type="button" class="zap-cancel">Cancel</button>
<button type="button" class="zap-save-default">Save as default</button>
<button type="button" class="zap-send">Send</button>
</div>
</div>
`;
const amountEl = overlay.querySelector('.zap-selector-amount');
const commentEl = overlay.querySelector('.zap-selector-comment');
const sendBtn = overlay.querySelector('.zap-send');
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
const cancelBtn = overlay.querySelector('.zap-cancel');
const enableZapEl = overlay.querySelector('.zap-selector-enable');
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
const parseCurrentValues = () => {
const amountSats = Number(amountEl?.value || 0);
const comment = String(commentEl?.value || '').trim();
const shouldZap = Boolean(enableZapEl?.checked);
return { amountSats, comment, shouldZap };
};
const updateBalanceWarning = () => {
if (!warningEl) return;
const { amountSats } = parseCurrentValues();
const balance = Number(balanceSats);
const insufficient = Number.isFinite(balance)
&& balance >= 0
&& Number.isFinite(amountSats)
&& amountSats > balance;
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
warningEl.style.display = insufficient ? '' : 'none';
};
const selectPreset = (button) => {
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
if (amountEl && button?.dataset?.amount) {
amountEl.value = button.dataset.amount;
}
updateBalanceWarning();
};
presetButtons.forEach((button) => {
button.addEventListener('click', () => selectPreset(button));
});
amountEl?.addEventListener('input', () => {
presetButtons.forEach((btn) => btn.classList.remove('active'));
updateBalanceWarning();
});
saveDefaultBtn?.addEventListener('click', async () => {
const { amountSats, comment } = parseCurrentValues();
if (!Number.isFinite(amountSats) || amountSats <= 0) {
amountEl?.focus();
return;
}
if (typeof onSaveDefault === 'function') {
try {
await onSaveDefault({ amountSats, comment });
saveDefaultBtn.textContent = 'Saved';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
} catch (error) {
console.error('[zaps] Save as default failed:', error?.message || error, error);
saveDefaultBtn.textContent = 'Save failed';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
}
return;
}
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
saveDefaultBtn.textContent = 'Unavailable';
setTimeout(() => {
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
}, 1200);
});
sendBtn?.addEventListener('click', () => {
const { amountSats, comment, shouldZap } = parseCurrentValues();
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
amountEl?.focus();
return;
}
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
});
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
overlay.addEventListener('click', (event) => {
if (event.target === overlay) closeActiveZapModal(null, resolve);
});
const onEscape = (event) => {
if (event.key !== 'Escape') return;
document.removeEventListener('keydown', onEscape);
closeActiveZapModal(null, resolve);
};
document.addEventListener('keydown', onEscape);
activeZapModalEl = overlay;
document.body.appendChild(overlay);
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
if (defaultPreset) {
selectPreset(defaultPreset);
} else {
presetButtons.forEach((btn) => btn.classList.remove('active'));
}
updateBalanceWarning();
amountEl?.focus();
});
}
@@ -506,3 +775,170 @@ export function extractZapComment(event) {
}
return '';
}
// =============================================================================
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
// =============================================================================
//
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
// available for a given recipient pubkey:
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
// one of those mints overlaps with the sender's wallet mints
// - sharedMints: the overlapping mint URLs (empty when no overlap)
// - nutzapMints: all mints declared on the recipient's kind 10019
// - lud16: the recipient's Lightning address (or null)
//
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
// repeated feed renders do not refetch. The cache is best-effort and safe to
// clear on page navigation (see clearZapCapabilitiesCache).
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
/**
* Clear the in-memory zap-capability cache.
* Safe to call on page navigation; subsequent calls will refetch.
*/
export function clearZapCapabilitiesCache() {
zapCapCache.clear();
}
/**
* Normalize a mint URL for comparison (trim + strip trailing slashes).
* @param {string} url
* @returns {string}
*/
function normalizeMintForCompare(url) {
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
}
/**
* Resolve which zap rails are available for a given pubkey.
*
* @param {string} pubkey - The recipient's pubkey (hex)
* @param {Object} options
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
* @param {string} [options.logPrefix='[zaps]']
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
*/
export async function resolveZapCapabilities(pubkey, options = {}) {
const {
fetchProfile: fetchProfileFn,
fetchMintList,
getSenderMints,
useCache = true,
logPrefix = '[zaps]'
} = options;
const recipient = String(pubkey || '').trim();
if (!recipient) {
return {
canLightning: false,
canNutzap: false,
sharedMints: [],
nutzapMints: [],
lud16: null,
hasMintList: false
};
}
// --- Cache lookup ---------------------------------------------------------
const now = Date.now();
if (useCache) {
const cached = zapCapCache.get(recipient);
if (cached && cached.expiresAt > now) {
return cached.result;
}
}
// --- Lightning capability (lud16 from kind 0) -----------------------------
let lud16 = null;
let canLightning = false;
if (typeof fetchProfileFn === 'function') {
try {
const profile = await fetchProfileFn(recipient);
const normalized = normalizeLud16(profile?.lud16);
if (normalized) {
lud16 = normalized;
canLightning = true;
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
}
}
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
let nutzapMints = [];
let sharedMints = [];
let canNutzap = false;
let hasMintList = false;
if (typeof fetchMintList === 'function') {
try {
const mintList = await fetchMintList(recipient);
const rawMints = Array.isArray(mintList?.mints)
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
nutzapMints = Array.from(new Set(rawMints));
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
if (hasMintList) {
// Determine the sender's wallet mints to compute overlap.
let senderMints = [];
if (typeof getSenderMints === 'function') {
try {
const senderResult = await getSenderMints();
senderMints = Array.isArray(senderResult?.mints)
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
: [];
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
}
}
if (senderMints.length > 0) {
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
canNutzap = sharedMints.length > 0;
} else {
// No sender wallet mints available yet — cannot confirm a shared mint,
// so we do not claim canNutzap. The recipient still has a mint list,
// which the UI may render in a muted state.
canNutzap = false;
}
}
} catch (error) {
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
}
}
const result = {
canLightning,
canNutzap,
sharedMints,
nutzapMints,
lud16,
hasMintList
};
// --- Cache store ----------------------------------------------------------
zapCapCache.set(recipient, {
result,
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
});
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
recipient: recipient.slice(0, 8) + '…',
canLightning,
canNutzap,
hasMintList,
sharedMints: sharedMints.length,
nutzapMints: nutzapMints.length
});
return result;
}

View File

@@ -294,7 +294,22 @@ let lightwalletHasWallet = false;
let spendingHistoryLastFetchAtMs = 0;
let spendingHistoryFetchPromise = null;
// Nutzap mint list (kind 10019) cache for followed users — proactively
// fetched on startup so resolveZapCapabilities() can hit IDB instead of
// going to relays for every followed author.
const NUTZAP_MINTLIST_DB_NAME = 'ndk-nutzap-mintlists';
const NUTZAP_MINTLIST_DB_VERSION = 1;
const NUTZAP_MINTLIST_STORE = 'mintlists'; // keyPath: pubkey
const NUTZAP_MINTLIST_PREFETCH_BATCH = 50; // max authors per fetchEvents call
const NUTZAP_MINTLIST_PREFETCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h freshness guard
// Relay activity tracking
// When false (default), broadcastRelayEvent skips the cross-page broadcast()
// and logRelayEvent skips the IndexedDB write. trackRelayRead/trackRelayWrite
// (which emit relayActivity events for footer/sidenav carrots) are NOT gated
// by this flag and always run. Toggled on by the relays page via the
// setRelayEventLogging RPC when the user opens connection history.
let relayEventLoggingEnabled = false;
const RELAY_EVENT_LOG_LIMIT = 200;
const RELAY_EVENTS_DB_NAME = 'relay-events';
const RELAY_EVENTS_DB_VERSION = 2;
@@ -600,6 +615,89 @@ async function openRelayEventsDb() {
});
}
// -----------------------------------------------------------------------------
// Nutzap mint list (kind 10019) IDB cache for followed users
// -----------------------------------------------------------------------------
async function openNutzapMintListDb() {
if (!HAS_INDEXEDDB || !INDEXEDDB_USABLE) {
throw new Error('IndexedDB unavailable or unhealthy');
}
return await new Promise((resolve, reject) => {
const req = indexedDB.open(NUTZAP_MINTLIST_DB_NAME, NUTZAP_MINTLIST_DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.createObjectStore(NUTZAP_MINTLIST_STORE, { keyPath: 'pubkey' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
/**
* Store a parsed kind 10019 mint list for a pubkey in IDB.
* @param {string} pubkey
* @param {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }} entry
*/
async function putNutzapMintListToDb(pubkey, entry) {
if (!pubkey) return;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return;
}
await new Promise((resolve, reject) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readwrite');
tx.objectStore(NUTZAP_MINTLIST_STORE).put({
pubkey,
...entry,
cachedAt: Date.now()
});
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
db.close();
} catch (error) {
console.warn('[Worker] putNutzapMintListToDb failed', error?.message || error);
}
}
/**
* Read a cached kind 10019 mint list for a pubkey from IDB.
* Returns null when missing or stale (older than NUTZAP_MINTLIST_PREFETCH_TTL_MS).
* @param {string} pubkey
* @returns {Promise<Object|null>}
*/
async function getNutzapMintListFromDb(pubkey) {
if (!pubkey) return null;
try {
const db = await openNutzapMintListDb();
if (!db.objectStoreNames.contains(NUTZAP_MINTLIST_STORE)) {
db.close();
return null;
}
const row = await new Promise((resolve) => {
const tx = db.transaction(NUTZAP_MINTLIST_STORE, 'readonly');
const req = tx.objectStore(NUTZAP_MINTLIST_STORE).get(pubkey);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
db.close();
if (!row) return null;
if (Date.now() - Number(row.cachedAt || 0) > NUTZAP_MINTLIST_PREFETCH_TTL_MS) {
return null; // stale
}
return row;
} catch (error) {
console.warn('[Worker] getNutzapMintListFromDb failed', error?.message || error);
return null;
}
}
function serializeRelayEventData(data) {
if (data === null || data === undefined) return null;
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return data;
@@ -816,13 +914,24 @@ function logRelayEvent(relayUrl, eventType, data = undefined, side = 'relay', ti
data: serializeRelayEventData(data)
};
// Fire-and-forget persistence so relay handling is never blocked by IndexedDB I/O.
void writeRelayEventToDb(entry);
// Only persist to IndexedDB when relay event logging is enabled.
// When the relays page (connection history) is closed, this avoids
// IndexedDB write contention from the high-frequency relay event stream.
if (relayEventLoggingEnabled) {
// Fire-and-forget persistence so relay handling is never blocked by IndexedDB I/O.
void writeRelayEventToDb(entry);
}
return entry;
}
function broadcastRelayEvent(relayUrl, eventType, data = undefined, side = 'relay') {
// Skip the cross-page broadcast entirely when relay event logging is off.
// This eliminates relayEvent message spam to every open tab/page when the
// connection-history UI is not visible. trackRelayRead/trackRelayWrite
// (relayActivity events) are intentionally NOT gated and still broadcast.
if (!relayEventLoggingEnabled) return;
const entry = logRelayEvent(relayUrl, eventType, data, side, Date.now());
if (!entry) return;
@@ -1867,6 +1976,22 @@ async function fetchStartupEventDownloads(pubkey) {
await processIncomingNutzaps(eventsByKey.kind9321 || [], { context: 'startup-fetch' });
broadcastStartupStatus('Startup downloads complete', { stage: 'startup-downloads', state: 'done', counts });
// Tier 1: proactively prefetch kind 10019 nutzap mint lists for all
// followed users so resolveZapCapabilities() can hit IDB. This runs in the
// background and must not block startup completion, so we do not await it.
try {
const kind3Events = Array.from(eventsByKey.kind3 || []);
const latestKind3 = kind3Events
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0] || null;
if (latestKind3) {
prefetchFollowedNutzapMintLists(latestKind3).catch((error) => {
console.warn('[Worker] background kind 10019 prefetch failed', error?.message || error);
});
}
} catch (prefetchError) {
console.warn('[Worker] kind 10019 prefetch scheduling failed', prefetchError?.message || prefetchError);
}
}
function ensureStartupWalletSubscriptions(pubkey) {
@@ -3205,6 +3330,142 @@ async function handleWalletPublishMintList(requestId, relays, receiveMints, port
}
}
/**
* Parse a kind 3 (contact list) event into a de-duplicated array of followed
* pubkeys (the 'p' tags).
* @param {Object|null|undefined} kind3Event
* @returns {string[]}
*/
function extractFollowedPubkeysFromKind3(kind3Event) {
if (!kind3Event || !Array.isArray(kind3Event.tags)) return [];
const pubkeys = new Set();
for (const tag of kind3Event.tags) {
if (Array.isArray(tag) && tag[0] === 'p' && tag[1] && String(tag[1]).length >= 32) {
pubkeys.add(String(tag[1]));
}
}
return Array.from(pubkeys);
}
/**
* Parse a raw kind 10019 event into the mint-list shape used by the cache.
* @param {Object} event
* @returns {{ hasMintList:boolean, mints:string[], relays:string[], p2pk:string|null, eventId:string|null, created_at:number }}
*/
function parseKind10019Event(event) {
const mints = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const relays = Array.from(new Set((event.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (event.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
return {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: event.id || null,
created_at: Number(event.created_at || 0)
};
}
/**
* Proactively fetch kind 10019 (nutzap mint lists) for all followed pubkeys
* discovered in the user's kind 3 contact list, and cache the results in
* IndexedDB. This is the Tier 1 fetch strategy: followed users are pre-warmed
* on startup so resolveZapCapabilities() can hit IDB instead of relays.
*
* Runs in the background and never rejects — failures are logged only.
*
* @param {Object|null|undefined} kind3Event - the user's kind 3 contact list
*/
async function prefetchFollowedNutzapMintLists(kind3Event) {
if (!ndk) return;
const pubkeys = extractFollowedPubkeysFromKind3(kind3Event);
if (pubkeys.length === 0) {
broadcastStartupStatus('No followed pubkeys to prefetch nutzap mint lists for', {
stage: 'kind10019-prefetch',
state: 'skipped'
});
return;
}
broadcastStartupStatus(`Prefetching nutzap mint lists (kind 10019) for ${pubkeys.length} followed users...`, {
stage: 'kind10019-prefetch',
state: 'start',
count: pubkeys.length
});
const startedAt = Date.now();
let stored = 0;
try {
// Fetch in batches to keep relay filters reasonable.
for (let i = 0; i < pubkeys.length; i += NUTZAP_MINTLIST_PREFETCH_BATCH) {
const batch = pubkeys.slice(i, i + NUTZAP_MINTLIST_PREFETCH_BATCH);
try {
const events = await ndk.fetchEvents({
kinds: [10019],
authors: batch
});
const eventList = Array.from(events || []);
// Group events by author and keep only the latest per pubkey.
const latestByAuthor = new Map();
for (const event of eventList) {
const prev = latestByAuthor.get(event.pubkey);
if (!prev || Number(event.created_at || 0) > Number(prev.created_at || 0)) {
latestByAuthor.set(event.pubkey, event);
}
}
for (const [author, event] of latestByAuthor) {
const parsed = parseKind10019Event(event);
await putNutzapMintListToDb(author, parsed);
stored++;
}
// Record an explicit "no mint list" entry for followed pubkeys
// that returned nothing, so we can short-circuit future lookups
// within the TTL window.
for (const author of batch) {
if (!latestByAuthor.has(author)) {
await putNutzapMintListToDb(author, {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
});
}
}
} catch (batchError) {
console.warn('[Worker] kind 10019 prefetch batch failed', batchError?.message || batchError);
}
}
broadcastStartupStatus(`Nutzap mint list prefetch done (${stored} stored)`, {
stage: 'kind10019-prefetch',
state: 'done',
count: stored,
durationMs: Date.now() - startedAt
});
} catch (error) {
broadcastStartupStatus('Nutzap mint list prefetch failed', {
stage: 'kind10019-prefetch',
state: 'failed',
error: error?.message || String(error),
durationMs: Date.now() - startedAt
});
console.warn('[Worker] prefetchFollowedNutzapMintLists failed', error?.message || error);
}
}
async function handleWalletFetchMintList(requestId, targetPubkey, port) {
try {
if (!ndk) throw new Error('NDK not initialized');
@@ -3214,6 +3475,30 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
throw new Error('Target pubkey is required');
}
// Tier 2 lazy fetch: consult the IDB cache (populated by the Tier 1
// startup prefetch) before hitting relays. A fresh cache hit avoids a
// round-trip for non-followed-but-recently-seen pubkeys too.
try {
const cached = await getNutzapMintListFromDb(author);
if (cached) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: Boolean(cached.hasMintList),
mints: Array.isArray(cached.mints) ? cached.mints : [],
relays: Array.isArray(cached.relays) ? cached.relays : [],
p2pk: cached.p2pk || null,
eventId: cached.eventId || null,
created_at: Number(cached.created_at || 0)
}
});
return;
}
} catch (_cacheError) {
// Fall through to relay fetch on any cache error.
}
const events = await ndk.fetchEvents({
kinds: [10019],
authors: [author],
@@ -3224,43 +3509,35 @@ async function handleWalletFetchMintList(requestId, targetPubkey, port) {
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))[0];
if (!latest) {
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null
}
});
const emptyEntry = {
hasMintList: false,
mints: [],
relays: [],
p2pk: null,
eventId: null,
created_at: 0
};
// Cache the negative result so repeated lookups are cheap.
putNutzapMintListToDb(author, emptyEntry).catch(() => {});
port.postMessage({ type: 'response', requestId, data: emptyEntry });
return;
}
const mints = Array.from(new Set((latest.tags || [])
.filter((tag) => tag?.[0] === 'mint' && tag?.[1])
.map((tag) => normalizeMintUrl(tag[1]))
.filter(Boolean)));
const parsed = parseKind10019Event(latest);
const relays = Array.from(new Set((latest.tags || [])
.filter((tag) => tag?.[0] === 'relay' && tag?.[1])
.map((tag) => String(tag[1]).trim())
.filter((url) => /^wss?:\/\//i.test(url))));
const p2pkTag = (latest.tags || []).find((tag) => tag?.[0] === 'pubkey' && tag?.[1]);
const p2pk = normalizeCashuP2pk(p2pkTag?.[1] || '') || null;
// Cache the relay result for future lookups.
putNutzapMintListToDb(author, parsed).catch(() => {});
port.postMessage({
type: 'response',
requestId,
data: {
hasMintList: mints.length > 0,
mints,
relays,
p2pk,
eventId: latest.id || null,
created_at: Number(latest.created_at || 0)
hasMintList: parsed.hasMintList,
mints: parsed.mints,
relays: parsed.relays,
p2pk: parsed.p2pk,
eventId: parsed.eventId,
created_at: parsed.created_at
}
});
} catch (error) {
@@ -6085,6 +6362,196 @@ async function handleNdkFetchEvents(requestId, filters, port) {
}
}
// Handle outbox pre-warm request — proactively resolves each follow's kind
// 10002 relay list via ndk.outboxTracker.trackUsers so that subsequent
// short-lived fetchEvents subscriptions route to the correct write relays
// instead of racing the tracker. Pre-warming is best-effort: this handler
// never throws; it always responds with an ok/skipped/error payload.
async function handleWarmOutbox(requestId, pubkeys, port) {
try {
if (!ndk?.outboxTracker) {
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: true,
count: 0,
skipped: true
});
return;
}
const list = Array.isArray(pubkeys) ? pubkeys.filter(Boolean) : [];
if (list.length === 0) {
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: true,
count: 0,
skipped: true
});
return;
}
await ndk.outboxTracker.trackUsers(list);
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: true,
count: list.length
});
} catch (err) {
console.warn('[Worker] warmOutbox failed:', err?.message || err);
port.postMessage({
type: 'warmOutboxResult',
requestId,
ok: false,
error: err?.message || String(err)
});
}
}
// Handle relay event logging toggle. When enabled, broadcastRelayEvent will
// emit relayEvent messages to all ports and logRelayEvent will persist entries
// to IndexedDB. When disabled (default), both are skipped to avoid cross-page
// broadcast spam and IndexedDB write contention. trackRelayRead/trackRelayWrite
// are NOT affected by this flag.
async function handleSetRelayEventLogging(requestId, enabled, port) {
try {
relayEventLoggingEnabled = !!enabled;
port.postMessage({
type: 'setRelayEventLoggingResult',
requestId,
ok: true,
enabled: relayEventLoggingEnabled
});
} catch (err) {
console.warn('[Worker] setRelayEventLogging failed:', err?.message || err);
port.postMessage({
type: 'setRelayEventLoggingResult',
requestId,
ok: false,
error: err?.message || String(err),
enabled: relayEventLoggingEnabled
});
}
}
// Handle getDiscoveredRelays request — returns relays currently in the NDK
// pool that are NOT part of the user's own kind 10002 relay list (relayTypes).
// These are temporary outbox relays NDK connected to in order to fetch events
// from followed authors who write to relays the user does not subscribe to.
// For each discovered relay we also report which followed pubkeys it serves,
// by inverting ndk.outboxTracker.data (pubkey -> OutboxItem{writeRelays}).
async function handleGetDiscoveredRelays(requestId, port) {
try {
if (!ndk?.pool?.relays) {
port.postMessage({
type: 'getDiscoveredRelaysResult',
requestId,
relays: []
});
return;
}
// Build the set of the user's own relay URLs (normalized) from
// relayTypes (kind 10002). These are the "configured" relays; anything
// else in the pool is a discovered/temporary outbox relay.
const ownRelays = new Set();
for (const url of relayTypes.keys()) {
const normalized = normalizeRelayUrl(url);
if (normalized) ownRelays.add(normalized);
}
// Build relayUrl -> [pubkeys] map by inverting the outbox tracker.
// ndk.outboxTracker.data is an LRUCache (typescript-lru-cache).
// We try a few iteration strategies to be resilient to API differences.
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
try {
const trackerData = ndk?.outboxTracker?.data;
if (trackerData) {
const collectForPubkey = (pubkey) => {
try {
const outboxItem = trackerData.get(pubkey);
const writeRelays = outboxItem?.writeRelays;
if (!writeRelays) return;
for (const r of writeRelays) {
const rUrl = r?.url ? normalizeRelayUrl(r.url) : (typeof r === 'string' ? normalizeRelayUrl(r) : null);
if (!rUrl) continue;
if (!relayServesPubkeys.has(rUrl)) {
relayServesPubkeys.set(rUrl, new Set());
}
relayServesPubkeys.get(rUrl).add(pubkey);
}
} catch (_) {
// ignore per-pubkey errors
}
};
// Strategy 1: standard Map-like entries()
let iterated = false;
try {
if (typeof trackerData.entries === 'function') {
for (const [pubkey] of trackerData.entries()) {
collectForPubkey(pubkey);
}
iterated = true;
}
} catch (_) {}
// Strategy 2: keys() + get()
if (!iterated) {
try {
if (typeof trackerData.keys === 'function') {
for (const pubkey of trackerData.keys()) {
collectForPubkey(pubkey);
}
iterated = true;
}
} catch (_) {}
}
// Strategy 3: iterate as Map directly
if (!iterated && trackerData instanceof Map) {
for (const pubkey of trackerData.keys()) {
collectForPubkey(pubkey);
}
}
}
} catch (err) {
console.warn('[Worker] getDiscoveredRelays: outbox tracker inversion failed:', err?.message || err);
}
const relays = [];
for (const relay of ndk.pool.relays.values()) {
const normalized = normalizeRelayUrl(relay?.url);
if (!normalized) continue;
// Skip relays that are part of the user's own kind 10002 list.
if (ownRelays.has(normalized)) continue;
const servingSet = relayServesPubkeys.get(normalized);
relays.push({
url: relay.url,
status: relay.status,
connected: relay.status >= 5,
servingPubkeys: servingSet ? Array.from(servingSet) : []
});
}
port.postMessage({
type: 'getDiscoveredRelaysResult',
requestId,
relays
});
} catch (err) {
console.warn('[Worker] getDiscoveredRelays failed:', err?.message || err);
port.postMessage({
type: 'getDiscoveredRelaysResult',
requestId,
relays: []
});
}
}
function handleSignResponse(requestId, result, error) {
const pending = pendingSignRequests.get(requestId);
@@ -6390,7 +6857,9 @@ self.onconnect = (event) => {
playlistIdentifier,
ownerPubkey,
playlistKind,
eventIds
eventIds,
pubkeys,
enabled
} = e.data;
switch(type) {
@@ -6430,6 +6899,20 @@ self.onconnect = (event) => {
await handleNdkFetchEvents(requestId, filters, port);
break;
case 'warmOutbox':
await handleWarmOutbox(requestId, pubkeys, port);
break;
case 'setRelayEventLogging':
await handleSetRelayEventLogging(requestId, enabled, port);
break;
case 'getDiscoveredRelays':
// Don't await — run fire-and-forget so this doesn't block
// the message queue (getRelayData/getRelayStats must stay responsive).
void handleGetDiscoveredRelays(requestId, port);
break;
case 'setActiveSigningPort':
activeSigningPort = port;
console.log('[Worker] Active signing port set explicitly by page');

1278
www/post-feed.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
<title>RELAYS</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/sidenav-sections.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
@@ -256,6 +257,10 @@
================================================================ -->
<div id="divBody" class="clsBodyFull">
<div id="divRelays">Loading relays...</div>
<div id="divDiscoveredRelaysWrap" style="width:95%;margin:10px auto 0 auto;">
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
<div id="divDiscoveredRelays" style="display:none;"></div>
</div>
<div id="divRelayEventsWrap">
<div id="divRelayEvents" class="relay-events-stream"></div>
</div>
@@ -332,7 +337,7 @@
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents } from './js/init-ndk.mjs';
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
@@ -370,6 +375,7 @@ const versionInfo = await getVersion();
let selectedRelayUrl = null; // Relay row currently selected for debug event history
const relayEventHistory = new Map(); // relay.url -> [{ timestamp, rawTimestamp, eventType, side, summary }]
const relayUiStats = new Map(); // relay.url -> { readCount, writeCount }
let connectionHistoryEnabled = false; // Toggled via sidenav; gates live relay event logging
const RELAY_EVENTS_DB_NAME = 'relay-events';
const RELAY_EVENTS_DB_VERSION = 1;
@@ -417,7 +423,45 @@ const versionInfo = await getVersion();
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Relay management options coming soon...";
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
connectionHistoryEnabled = historyEnabled;
divSideNavBody.innerHTML = `
<div id="divRelaySettings" style="display:flex;align-items:center;padding:4px 10px;cursor:pointer;font-size:80%;color:var(--primary-color);">
<span style="flex:1;">Show connection history</span>
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
</div>
`;
// Wire up the connection history toggle
const divHistoryToggle = document.getElementById('divHistoryToggleCheckbox');
if (divHistoryToggle) {
const toggleHistory = () => {
const checked = !connectionHistoryEnabled;
connectionHistoryEnabled = checked;
localStorage.setItem('relayConnectionHistory', checked);
setRelayEventLogging(checked);
divHistoryToggle.innerHTML = checked ? SVG_CHECKED : SVG_UNCHECKED;
const wrap = document.getElementById('divRelayEventsWrap');
if (wrap) {
wrap.style.display = checked ? '' : 'none';
}
if (checked) {
if (selectedRelayUrl) {
loadRelayEventsFromDb(selectedRelayUrl);
}
renderRelayEventsForSelectedRelay();
} else {
if (divRelayEvents) {
divRelayEvents.innerHTML = '';
}
}
};
divHistoryToggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleHistory();
});
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
@@ -942,7 +986,9 @@ const versionInfo = await getVersion();
if (!relayUrl) return;
selectedRelayUrl = normalizeRelayUrlForDb(relayUrl);
await createRelayTable(currentRelayList);
await loadRelayEventsFromDb(selectedRelayUrl);
if (connectionHistoryEnabled) {
await loadRelayEventsFromDb(selectedRelayUrl);
}
renderRelayEventsForSelectedRelay();
};
@@ -1267,10 +1313,101 @@ const versionInfo = await getVersion();
}
if (selectedRelayUrl === normalizedRelayUrl) {
renderRelayEventsForSelectedRelay();
// Incremental append instead of full re-render
const sideClass = side === 'client' ? 'client' : 'relay';
const label = side === 'client' ? 'CLIENT ➜ RELAY' : 'RELAY ➜ CLIENT';
const dataPart = summary ? `\n${summary}` : '';
const text = `[${timestamp}] ${label} ${eventType.toUpperCase()}${dataPart}`;
const row = document.createElement('div');
row.className = `relay-event-row ${sideClass}`;
const bubble = document.createElement('div');
bubble.className = 'relay-event-bubble';
bubble.textContent = text;
row.appendChild(bubble);
divRelayEvents.appendChild(row);
// Remove oldest if over 1000
while (divRelayEvents.children.length > 1000) {
divRelayEvents.removeChild(divRelayEvents.firstChild);
}
divRelayEvents.scrollTop = divRelayEvents.scrollHeight;
}
};
const shortNpub = (hex) => {
if (!hex || hex.length < 16) return hex || '-';
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
};
let discoveredRelaysExpanded = false;
const renderDiscoveredRelays = (relays) => {
const container = document.getElementById('divDiscoveredRelays');
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!container || !title) return;
if (!discoveredRelaysExpanded) {
container.style.display = 'none';
title.textContent = `▸ Discovered Relays (Outbox)${relays.length > 0 ? ` (${relays.length})` : ''}`;
return;
}
title.textContent = `▾ Discovered Relays (Outbox) (${relays.length})`;
container.style.display = '';
if (relays.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
html += '<thead><tr>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Sample Follows</th>';
html += '</tr></thead><tbody>';
for (const relay of relays) {
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
const sampleFollows = Array.isArray(relay.servingPubkeys)
? relay.servingPubkeys.slice(0, 3).map(shortNpub).join(', ')
: '-';
const rowClass = relay.connected ? 'tblRowConnected' : '';
html += `<tr class="${rowClass}">`;
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;">${sampleFollows}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
};
const refreshDiscoveredRelays = async () => {
try {
const relays = await getDiscoveredRelays();
renderDiscoveredRelays(Array.isArray(relays) ? relays : []);
} catch (error) {
console.warn('[relays.html] Failed to fetch discovered relays:', error?.message || error);
renderDiscoveredRelays([]);
}
};
const initDiscoveredRelaysToggle = () => {
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!title) return;
title.addEventListener('click', () => {
discoveredRelaysExpanded = !discoveredRelaysExpanded;
refreshDiscoveredRelays();
});
};
const refreshRelayData = async () => {
console.log('[relay2.html] Refreshing relay data...');
try {
@@ -1403,6 +1540,7 @@ const versionInfo = await getVersion();
// Note: We need to listen on the worker port, not window
// This will be set up via a custom event from init-ndk.mjs
window.addEventListener('ndkRelayEvent', (event) => {
if (!connectionHistoryEnabled) return;
const { relayUrl, event: eventType, data, timestamp, side } = event.detail;
logRelayEvent(relayUrl, eventType, data, timestamp, side);
});
@@ -1433,6 +1571,9 @@ const versionInfo = await getVersion();
await loadCurrentDmInboxRelayList();
// Initialize discovered relays (outbox) collapsible toggle
initDiscoveredRelaysToggle();
// Get initial relay data
await refreshRelayData();
@@ -1444,7 +1585,27 @@ const versionInfo = await getVersion();
// Refresh relay table every 5 seconds
setInterval(refreshRelayData, 5000);
// Discovered relays are fetched only on manual expand click — no
// auto-refresh interval, to avoid blocking the worker's message queue.
// Sync connection history toggle state from localStorage
connectionHistoryEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
setRelayEventLogging(connectionHistoryEnabled);
if (!connectionHistoryEnabled) {
const wrap = document.getElementById('divRelayEventsWrap');
if (wrap) {
wrap.style.display = 'none';
}
}
// Stop the worker from broadcasting relay events when the page is closing
window.addEventListener('beforeunload', () => {
if (connectionHistoryEnabled) {
setRelayEventLogging(false);
}
});
console.log('[relay2.html] Initialization complete');
} catch (error) {
console.error('[relay2.html] Initialization failed:', error);