Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e945f8de | ||
|
|
3ec585273f | ||
|
|
0e8229e13e | ||
|
|
230bd70e07 | ||
|
|
c36cb4a0fe | ||
|
|
d6b167a50b | ||
|
|
27f4cd6857 | ||
|
|
28c8109f8a | ||
|
|
00e93b2b2f | ||
|
|
2334f742cf | ||
|
|
8c456ec522 | ||
|
|
e734f33542 | ||
|
|
6ad2d37a23 | ||
|
|
28ce7bce1b | ||
|
|
894a163111 | ||
|
|
efe9d977cd | ||
|
|
bff0bca411 | ||
|
|
fee3ffc764 | ||
|
|
d49a3e800f | ||
|
|
9d9d0f88f5 | ||
|
|
b11161862a | ||
|
|
fe35b2dfa1 | ||
|
|
ae67e65969 | ||
|
|
19344942b0 | ||
|
|
c758333269 | ||
|
|
3ed0c35611 | ||
|
|
d9503c1532 | ||
|
|
fa0859dcdd | ||
|
|
0c340316ae | ||
|
|
313a55992f | ||
|
|
ece3a136c1 | ||
|
|
543ae6a1b3 | ||
|
|
508952a1ea | ||
|
|
15ef106eb5 | ||
|
|
a42edcf6d6 | ||
|
|
0267233f87 | ||
|
|
6b45092bc8 | ||
|
|
6d39188967 | ||
|
|
c5f682c5fe |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# GPT-2 model files (271MB, too large for git — uploaded directly to server)
|
||||
www/models/
|
||||
|
||||
hamburger_morphing/
|
||||
client/
|
||||
ndk/
|
||||
|
||||
934
plans/feed-outbox-assessment.md
Normal file
934
plans/feed-outbox-assessment.md
Normal 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 481–545)
|
||||
|
||||
[`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 1–3) 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 1–3) 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 1–3 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 1–3) | 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 553–748) —
|
||||
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 859–939, 1200–1272) — 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 1–3), 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.5–1.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 2–3 | Pending |
|
||||
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 3–5 | Partial (6.1–6.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 1–4 are complete** (deployed v0.7.40–v0.7.41) — they fix the feed
|
||||
outbox routing and the relays.html connection history performance issue.
|
||||
Phases 5–6 (discovered relays visualization) are the next priority. Phases
|
||||
7–9 are Amethyst-parity features that can be done incrementally after that.
|
||||
@@ -48,18 +48,18 @@ Remove inline replies from the feed. Show only the original post with interactio
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **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.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
|
||||
|
||||
- [ ] **1.2** Change the comment button behavior — instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=<eventId>` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`.
|
||||
- [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')`.
|
||||
|
||||
- [ ] **1.3** Keep the interaction bar functional in the feed:
|
||||
- [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)
|
||||
- ✅ Comment button — opens `post-feed.html?event=<eventId>` in a new tab
|
||||
- ✅ Quote button — opens quote composer (no navigation)
|
||||
|
||||
- [ ] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) — the feed should still update these counts as events arrive, just not render the reply content inline.
|
||||
- [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
|
||||
|
||||
@@ -86,27 +86,28 @@ Accepts the same event identifier formats as the rest of the project:
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
|
||||
- [x] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
|
||||
|
||||
- [ ] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
|
||||
- [x] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
|
||||
|
||||
- [ ] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
|
||||
- [x] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
|
||||
|
||||
- [ ] **2.4** Fetch all replies — kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`.
|
||||
- [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.
|
||||
|
||||
- [ ] **2.5** Render the comment thread below the original post:
|
||||
- Flat thread (all replies in chronological order) — simplest, most reliable
|
||||
- Each reply rendered using `renderPostItem` from `post-interactions.mjs`
|
||||
- Start with flat; can add nesting/threading later
|
||||
- [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
|
||||
|
||||
- [ ] **2.6** Add an inline composer at the bottom for posting replies. The composer should:
|
||||
- Tag the original post: `['e', postId, '', 'reply']`
|
||||
- Tag the original author: `['p', postPubkey]`
|
||||
- Use the existing post composer component (`post-composer.mjs`)
|
||||
- [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)
|
||||
|
||||
- [ ] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive.
|
||||
- [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.
|
||||
|
||||
- [ ] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions.mjs`. The comment button on a reply opens `post-feed.html?event=<replyId>` in a new tab for that reply's sub-thread.
|
||||
- [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
|
||||
|
||||
@@ -116,6 +117,97 @@ Accepts the same event identifier formats as the rest of the project:
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -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 */
|
||||
/* ************************************************************************* */
|
||||
|
||||
@@ -87,7 +87,9 @@
|
||||
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" 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>
|
||||
@@ -279,10 +281,18 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
return entry;
|
||||
}
|
||||
|
||||
function handleComposerCommentIntent({ postId }) {
|
||||
if (!postId) return false;
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
||||
return true;
|
||||
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 }) {
|
||||
@@ -325,7 +335,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
ndkFetchEvents,
|
||||
queryCache,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
walletPayInvoice,
|
||||
@@ -630,6 +639,12 @@ 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();
|
||||
@@ -705,7 +720,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:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
|
||||
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>
|
||||
@@ -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,9 @@ 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);
|
||||
|
||||
// NDK.subscribe already calls outboxTracker.trackUsers internally,
|
||||
// so no explicit warmOutbox call is needed here.
|
||||
|
||||
subscribe(
|
||||
{ kinds: [1], authors, since },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
@@ -576,11 +581,20 @@ 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) {
|
||||
// NDK's fetchEvents/subscribe already calls outboxTracker.trackUsers
|
||||
// internally for author-based filters, so no explicit warmOutbox needed.
|
||||
try {
|
||||
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
|
||||
renderFeed();
|
||||
@@ -639,12 +653,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 +728,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>
|
||||
|
||||
@@ -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
|
||||
@@ -885,7 +1038,7 @@ export async function getRelayData() {
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set timeout
|
||||
// Set timeout (15s — worker may be busy processing outbox/NDK events)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
@@ -893,7 +1046,7 @@ export async function getRelayData() {
|
||||
console.warn('[init-ndk] Get relay data timeout, returning empty array');
|
||||
resolve([]);
|
||||
}
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getRelayData',
|
||||
@@ -918,7 +1071,7 @@ export async function getRelayStats() {
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set timeout
|
||||
// Set timeout (15s — worker may be busy processing outbox/NDK events)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
@@ -926,7 +1079,7 @@ export async function getRelayStats() {
|
||||
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
|
||||
resolve({});
|
||||
}
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getRelayStats',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
345
www/js/stego.mjs
Normal file
345
www/js/stego.mjs
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* LLM-based steganography — JavaScript port of stego.py.
|
||||
*
|
||||
* Half-splitting entropy coding on top of GPT-2's next-token distribution.
|
||||
* Both encoder and decoder run the same JS code with the same PRNG seed,
|
||||
* so they stay synchronized regardless of cross-language PRNG differences.
|
||||
*/
|
||||
|
||||
import { Tensor } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRNG — mulberry32 (deterministic given seed)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function random() {
|
||||
a = (a + 0x6D2B79F5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bit <-> string conversion (UTF-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function strToBits(s) {
|
||||
const data = new TextEncoder().encode(s);
|
||||
const bits = [];
|
||||
for (const byte of data) {
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
bits.push((byte >> i) & 1);
|
||||
}
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
export function bitsToStr(bits) {
|
||||
// Pad to a multiple of 8 (should already be).
|
||||
const padded = bits.slice();
|
||||
while (padded.length % 8 !== 0) padded.push(0);
|
||||
const bytes = new Uint8Array(Math.floor(padded.length / 8));
|
||||
for (let i = 0; i < padded.length; i += 8) {
|
||||
let byte = 0;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
byte = (byte << 1) | padded[i + j];
|
||||
}
|
||||
bytes[i / 8] = byte;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Softmax over a TypedArray / Array of logits (numerically stable)
|
||||
// ---------------------------------------------------------------------------
|
||||
function softmax(logits) {
|
||||
let max = -Infinity;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
if (logits[i] > max) max = logits[i];
|
||||
}
|
||||
const probs = new Float32Array(logits.length);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
const e = Math.exp(logits[i] - max);
|
||||
probs[i] = e;
|
||||
sum += e;
|
||||
}
|
||||
if (sum <= 0) sum = 1;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
probs[i] /= sum;
|
||||
}
|
||||
return probs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next-token probability distribution from the model.
|
||||
// Returns a Float32Array of length vocab_size.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function nextTokenProbs(model, tokenizer, tokenIds) {
|
||||
// Build the model inputs directly from the token-id array. We must NOT
|
||||
// pass the id array through tokenizer(): in Transformers.js v3 the
|
||||
// tokenizer call expects a string and will fail with
|
||||
// "e.split is not a function" when given numbers. Instead we construct
|
||||
// int64 tensors ourselves and feed them straight to the model.
|
||||
const seqLength = tokenIds.length;
|
||||
const idData = BigInt64Array.from(tokenIds, (x) => BigInt(x));
|
||||
const maskData = BigInt64Array.from({ length: seqLength }, () => 1n);
|
||||
const inputs = {
|
||||
input_ids: new Tensor("int64", idData, [1, seqLength]),
|
||||
attention_mask: new Tensor("int64", maskData, [1, seqLength]),
|
||||
};
|
||||
const output = await model(inputs);
|
||||
const logits = output.logits;
|
||||
|
||||
// logits.dims = [batch, seq_len, vocab_size]
|
||||
const dims = logits.dims;
|
||||
const seqLen = dims[1];
|
||||
const vocabSize = dims[2];
|
||||
const data = logits.data;
|
||||
|
||||
// Extract the last token's logits: row = (batch=0, last token, :)
|
||||
const offset = (seqLen - 1) * vocabSize;
|
||||
const lastLogits = new Float32Array(vocabSize);
|
||||
for (let i = 0; i < vocabSize; i++) {
|
||||
lastLogits[i] = data[offset + i];
|
||||
}
|
||||
return softmax(lastLogits);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split the sorted vocabulary into two halves at cumulative prob 0.50.
|
||||
// Returns { firstIds, firstProbs, secondIds, secondProbs }.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function splitHalves(probs) {
|
||||
const vocabSize = probs.length;
|
||||
|
||||
// Build an index array covering EVERY token id [0 .. vocabSize-1] and
|
||||
// sort it by probability descending. Sorting indices (rather than
|
||||
// dropping any) guarantees that all token ids are retained and that the
|
||||
// two slices below form a complete, disjoint partition of the vocabulary.
|
||||
// Ties are broken by ascending token id so the ordering is fully
|
||||
// deterministic — important under int8 quantization where many logits can
|
||||
// be exactly equal (an unstable sort could otherwise diverge between the
|
||||
// encoder and decoder passes).
|
||||
const sortedIds = new Array(vocabSize);
|
||||
for (let i = 0; i < vocabSize; i++) sortedIds[i] = i;
|
||||
sortedIds.sort((a, b) => {
|
||||
const diff = probs[b] - probs[a];
|
||||
if (diff !== 0) return diff;
|
||||
return a - b; // stable tie-break by token id
|
||||
});
|
||||
|
||||
// Find the split point: the first index at which the cumulative
|
||||
// probability mass reaches/exceeds 0.50. Everything up to and including
|
||||
// that token forms the first half; the remainder forms the second half.
|
||||
let cum = 0.0;
|
||||
let splitIdx = vocabSize; // default: all mass in first half (edge case)
|
||||
for (let i = 0; i < vocabSize; i++) {
|
||||
cum += probs[sortedIds[i]];
|
||||
if (cum >= 0.50) {
|
||||
splitIdx = i + 1; // include this token in the first half
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp so BOTH halves are guaranteed non-empty. The split index must be
|
||||
// at least 1 (first half non-empty) and at most vocabSize-1 (second half
|
||||
// non-empty). This handles degenerate distributions where a single token
|
||||
// already holds >= 0.50 of the mass, or where the mass never reaches 0.50.
|
||||
if (splitIdx < 1) splitIdx = 1;
|
||||
if (splitIdx > vocabSize - 1) splitIdx = vocabSize - 1;
|
||||
|
||||
// slice(0, splitIdx) + slice(splitIdx) ALWAYS covers the entire sorted
|
||||
// array, so every token id appears in exactly one half — none missing,
|
||||
// none duplicated. Ids are plain Numbers (0..vocabSize-1).
|
||||
const firstIds = sortedIds.slice(0, splitIdx);
|
||||
const secondIds = sortedIds.slice(splitIdx);
|
||||
const firstProbs = firstIds.map(id => probs[id]);
|
||||
const secondProbs = secondIds.map(id => probs[id]);
|
||||
|
||||
return { firstIds, firstProbs, secondIds, secondProbs };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample a token id from the given half using renormalized probabilities.
|
||||
// Advances the PRNG exactly once (one call to rng()).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function sampleWithinHalf(ids, probs, rng) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < probs.length; i++) total += probs[i];
|
||||
if (total <= 0) total = 1;
|
||||
const r = rng();
|
||||
let cum = 0.0;
|
||||
let chosen = ids[ids.length - 1];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
cum += probs[i] / total;
|
||||
if (r < cum) {
|
||||
chosen = ids[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encoder
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function encode(model, tokenizer, secretMessage, context, key, padTokens = 3, onToken = null) {
|
||||
const rng = mulberry32(key >>> 0);
|
||||
const bits = strToBits(secretMessage);
|
||||
const numBits = bits.length;
|
||||
const totalBits = numBits;
|
||||
const totalChars = secretMessage.length;
|
||||
|
||||
// Tokenize the context. Transformers.js tokenizer() returns an object
|
||||
// with input_ids; we extract the raw id array.
|
||||
const tokenIds = tokenizeToIds(tokenizer, context);
|
||||
|
||||
for (let bitIndex = 0; bitIndex < bits.length; bitIndex++) {
|
||||
const bit = bits[bitIndex];
|
||||
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
|
||||
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
|
||||
let chosen;
|
||||
if (bit === 0) {
|
||||
chosen = sampleWithinHalf(firstIds, firstProbs, rng);
|
||||
} else {
|
||||
chosen = sampleWithinHalf(secondIds, secondProbs, rng);
|
||||
}
|
||||
tokenIds.push(chosen);
|
||||
|
||||
// Stream the partial cover text back to the caller and yield to the
|
||||
// browser so the UI can repaint between (slow) forward passes.
|
||||
if (onToken) {
|
||||
const textSoFar = tokenizer.decode(tokenIds, { skip_special_tokens: true });
|
||||
const charIndex = Math.floor(bitIndex / 8) + 1;
|
||||
const currentChar = secretMessage[charIndex - 1] ?? "";
|
||||
onToken(textSoFar, bitIndex + 1, totalBits, currentChar, charIndex, totalChars);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// Padding tokens: sample from the full distribution using the same PRNG.
|
||||
for (let p = 0; p < padTokens; p++) {
|
||||
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
|
||||
// Sort descending.
|
||||
const pairs = [];
|
||||
for (let i = 0; i < probs.length; i++) pairs.push([probs[i], i]);
|
||||
pairs.sort((a, b) => b[0] - a[0]);
|
||||
const sp = pairs.map(x => x[0]);
|
||||
const si = pairs.map(x => x[1]);
|
||||
let total = 0;
|
||||
for (let i = 0; i < sp.length; i++) total += sp[i];
|
||||
if (total <= 0) total = 1;
|
||||
const r = rng();
|
||||
let cum = 0.0;
|
||||
let chosen = si[0];
|
||||
for (let i = 0; i < si.length; i++) {
|
||||
cum += sp[i] / total;
|
||||
if (r < cum) {
|
||||
chosen = si[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokenIds.push(chosen);
|
||||
}
|
||||
|
||||
const coverText = tokenizer.decode(tokenIds, { skip_special_tokens: true });
|
||||
return { coverText, numBits, tokenCount: tokenIds.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function decode(model, tokenizer, coverText, context, key, numBits, padTokens = 3, onProgress = null) {
|
||||
const rng = mulberry32(key >>> 0);
|
||||
const totalBits = numBits;
|
||||
|
||||
const contextIds = tokenizeToIds(tokenizer, context);
|
||||
const coverIds = tokenizeToIds(tokenizer, coverText);
|
||||
|
||||
// Generated tokens are everything after the context prefix.
|
||||
const genIds = coverIds.slice(contextIds.length);
|
||||
|
||||
const tokenIds = contextIds.slice();
|
||||
const bits = [];
|
||||
|
||||
for (const genToken of genIds) {
|
||||
if (bits.length >= numBits) break;
|
||||
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
|
||||
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
|
||||
|
||||
// splitHalves yields plain Number ids and partitions the entire
|
||||
// vocabulary, so a normalized (Number) genToken is guaranteed to be in
|
||||
// exactly one half. tokenizeToIds normalizes ids to Number, but coerce
|
||||
// defensively here too in case a BigInt slips through.
|
||||
const token = typeof genToken === "bigint" ? Number(genToken) : genToken;
|
||||
const firstSet = new Set(firstIds);
|
||||
const secondSet = new Set(secondIds);
|
||||
|
||||
let recoveredBit;
|
||||
if (firstSet.has(token)) {
|
||||
recoveredBit = 0;
|
||||
bits.push(0);
|
||||
// Advance PRNG identically to the encoder.
|
||||
sampleWithinHalf(firstIds, firstProbs, rng);
|
||||
} else if (secondSet.has(token)) {
|
||||
recoveredBit = 1;
|
||||
bits.push(1);
|
||||
sampleWithinHalf(secondIds, secondProbs, rng);
|
||||
} else {
|
||||
throw new Error(`Generated token ${token} not found in either half.`);
|
||||
}
|
||||
tokenIds.push(token);
|
||||
|
||||
// Report progress and yield so the UI can repaint between passes.
|
||||
if (onProgress) {
|
||||
const currentToken = tokenizer.decode([token], { skip_special_tokens: true });
|
||||
const completedBytes = Math.floor(bits.length / 8);
|
||||
const recoveredChars = bitsToStr(bits.slice(0, completedBytes * 8));
|
||||
onProgress(
|
||||
bits.length,
|
||||
totalBits,
|
||||
recoveredChars,
|
||||
currentToken,
|
||||
recoveredBit,
|
||||
);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
return bitsToStr(bits.slice(0, numBits));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: tokenize a string to a plain array of token ids.
|
||||
// Handles the various shapes Transformers.js may return.
|
||||
// ---------------------------------------------------------------------------
|
||||
function tokenizeToIds(tokenizer, text) {
|
||||
const inputs = tokenizer(text, { add_special_tokens: false });
|
||||
let ids;
|
||||
if (inputs && inputs.input_ids) {
|
||||
ids = inputs.input_ids;
|
||||
} else if (Array.isArray(inputs)) {
|
||||
ids = inputs;
|
||||
} else {
|
||||
ids = inputs;
|
||||
}
|
||||
// ids may be a Tensor or a nested array like [[1,2,3]].
|
||||
let arr;
|
||||
if (ids && typeof ids.data !== "undefined" && ids.dims) {
|
||||
arr = Array.from(ids.data);
|
||||
} else if (Array.isArray(ids)) {
|
||||
// Flatten one level if nested: [[...]] -> [...]
|
||||
arr = (ids.length > 0 && Array.isArray(ids[0])) ? ids[0].slice() : ids.slice();
|
||||
} else {
|
||||
// Fallback: try to iterate.
|
||||
arr = Array.from(ids);
|
||||
}
|
||||
// CRITICAL: token tensors are int64, so ids.data is a BigInt64Array and
|
||||
// Array.from() yields BigInts (e.g. 351n). splitHalves produces plain
|
||||
// Number ids, and `new Set([...Numbers]).has(351n)` is false — which is
|
||||
// exactly what caused "Generated token 351 not found in either half".
|
||||
// Normalize every id to a plain Number so membership checks line up.
|
||||
return arr.map(v => (typeof v === "bigint" ? Number(v) : Number(v)));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.25",
|
||||
"VERSION_NUMBER": "0.7.25",
|
||||
"BUILD_DATE": "2026-06-25T23:45:36.529Z"
|
||||
"VERSION": "v0.7.64",
|
||||
"VERSION_NUMBER": "0.7.64",
|
||||
"BUILD_DATE": "2026-06-29T00:45:25.458Z"
|
||||
}
|
||||
|
||||
444
www/js/zaps.mjs
444
www/js/zaps.mjs
@@ -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;
|
||||
}
|
||||
|
||||
1872
www/llm-steganography.html
Normal file
1872
www/llm-steganography.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,298 @@ 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}).
|
||||
function shortHexPubkey(hex) {
|
||||
if (!hex || hex.length < 16) return hex || '-';
|
||||
return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Cache for discovered relays — rebuilt only when the contact list changes.
|
||||
// The version suffix forces a rebuild when the handler code changes (e.g.
|
||||
// adding servingNames).
|
||||
let discoveredRelaysCache = null;
|
||||
let discoveredRelaysCacheKey = '';
|
||||
const DISCOVERED_RELAYS_CACHE_VERSION = 'v3-names-k0';
|
||||
|
||||
async function handleGetDiscoveredRelays(requestId, port) {
|
||||
try {
|
||||
if (!ndk) {
|
||||
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.
|
||||
const ownRelays = new Set();
|
||||
for (const url of relayTypes.keys()) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (normalized) ownRelays.add(normalized);
|
||||
}
|
||||
|
||||
// Get the user's kind 3 contact list from the Dexie cache to
|
||||
// determine the followed pubkeys. Use the contact list's created_at
|
||||
// as a cache key so we only rebuild when it changes.
|
||||
const kind3Events = await ndk.fetchEvents(
|
||||
{ kinds: [3], authors: [currentPubkey], limit: 1 },
|
||||
{ cacheUsage: 'ONLY_CACHE' }
|
||||
);
|
||||
const latestKind3 = kind3Events && kind3Events.size > 0
|
||||
? Array.from(kind3Events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||
: null;
|
||||
const followedPubkeys = latestKind3 ? extractFollowedPubkeysFromKind3(latestKind3) : [];
|
||||
const cacheKey = `${DISCOVERED_RELAYS_CACHE_VERSION}_${latestKind3?.id || 'none'}_${latestKind3?.created_at || 0}`;
|
||||
|
||||
// Rebuild the relay→[pubkeys] mapping from follows' kind 10002 events
|
||||
// in the Dexie cache if the contact list has changed.
|
||||
if (discoveredRelaysCacheKey !== cacheKey) {
|
||||
discoveredRelaysCacheKey = cacheKey;
|
||||
discoveredRelaysCache = null; // invalidate
|
||||
|
||||
if (followedPubkeys.length > 0) {
|
||||
// Query all follows' kind 10002 events from the Dexie cache.
|
||||
// This is the persistent data source — no 2-minute TTL like
|
||||
// the outbox tracker.
|
||||
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
|
||||
|
||||
// Process in batches of 400 to avoid filter size limits.
|
||||
for (let i = 0; i < followedPubkeys.length; i += 400) {
|
||||
const batch = followedPubkeys.slice(i, i + 400);
|
||||
try {
|
||||
const k10002Events = await ndk.fetchEvents(
|
||||
{ kinds: [10002], authors: batch },
|
||||
{ cacheUsage: 'ONLY_CACHE' }
|
||||
);
|
||||
if (k10002Events && k10002Events.size > 0) {
|
||||
// For each author, keep only the latest kind 10002.
|
||||
const latestByAuthor = new Map();
|
||||
for (const evt of k10002Events) {
|
||||
const existing = latestByAuthor.get(evt.pubkey);
|
||||
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
|
||||
latestByAuthor.set(evt.pubkey, evt);
|
||||
}
|
||||
}
|
||||
// Parse 'r' tags from each author's latest kind 10002.
|
||||
for (const [pubkey, evt] of latestByAuthor) {
|
||||
if (!Array.isArray(evt.tags)) continue;
|
||||
for (const tag of evt.tags) {
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
const relayType = tag[2] || 'both';
|
||||
// Include 'write' and 'both' relays — these are
|
||||
// where the follow posts content.
|
||||
if (relayType === 'write' || relayType === 'both') {
|
||||
const normalized = normalizeRelayUrl(tag[1]);
|
||||
if (!normalized) continue;
|
||||
if (!relayServesPubkeys.has(normalized)) {
|
||||
relayServesPubkeys.set(normalized, new Set());
|
||||
}
|
||||
relayServesPubkeys.get(normalized).add(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Look up profile names for all serving pubkeys from the Dexie
|
||||
// profiles table so we can display usernames instead of pubkeys.
|
||||
// Look up profile names for all serving pubkeys.
|
||||
const pubkeyToName = new Map();
|
||||
const allServingPubkeys = new Set();
|
||||
for (const servingSet of relayServesPubkeys.values()) {
|
||||
for (const pk of servingSet) allServingPubkeys.add(pk);
|
||||
}
|
||||
|
||||
// Strategy 1: Try Dexie profiles table.
|
||||
try {
|
||||
const adapter = ndk?.cacheAdapter?.adapter;
|
||||
if (adapter?.db?.profiles) {
|
||||
for (const pk of allServingPubkeys) {
|
||||
try {
|
||||
const row = await adapter.db.profiles.get(pk);
|
||||
if (row) {
|
||||
const name = row.displayName || row.name || '';
|
||||
if (name) pubkeyToName.set(pk, name);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Strategy 2: If Dexie profiles didn't have names for everyone,
|
||||
// query kind 0 events from the NDK cache for the missing pubkeys.
|
||||
const missingPubkeys = Array.from(allServingPubkeys).filter(pk => !pubkeyToName.has(pk));
|
||||
if (missingPubkeys.length > 0) {
|
||||
try {
|
||||
// Query in batches of 400.
|
||||
for (let i = 0; i < missingPubkeys.length; i += 400) {
|
||||
const batch = missingPubkeys.slice(i, i + 400);
|
||||
const k0Events = await ndk.fetchEvents(
|
||||
{ kinds: [0], authors: batch },
|
||||
{ cacheUsage: 'ONLY_CACHE' }
|
||||
);
|
||||
if (k0Events && k0Events.size > 0) {
|
||||
// For each author, keep only the latest kind 0.
|
||||
const latestByAuthor = new Map();
|
||||
for (const evt of k0Events) {
|
||||
const existing = latestByAuthor.get(evt.pubkey);
|
||||
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
|
||||
latestByAuthor.set(evt.pubkey, evt);
|
||||
}
|
||||
}
|
||||
for (const [pubkey, evt] of latestByAuthor) {
|
||||
try {
|
||||
const profile = typeof evt.content === 'string'
|
||||
? JSON.parse(evt.content) : evt.content;
|
||||
const name = profile?.display_name || profile?.name || '';
|
||||
if (name) pubkeyToName.set(pubkey, name);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
console.log('[Worker] getDiscoveredRelays: profile names resolved for', pubkeyToName.size, 'of', allServingPubkeys.size, 'pubkeys');
|
||||
|
||||
// Build the discovered relays list: relays that are NOT in the
|
||||
// user's own kind 10002 but ARE in follows' kind 10002.
|
||||
const relays = [];
|
||||
for (const [relayUrl, servingSet] of relayServesPubkeys) {
|
||||
if (ownRelays.has(relayUrl)) continue; // skip user's own relays
|
||||
// Check live connection status from the pool.
|
||||
const poolRelay = ndk.pool?.relays?.get(relayUrl);
|
||||
const pubkeys = Array.from(servingSet);
|
||||
// Map pubkeys to names, falling back to truncated pubkey.
|
||||
const names = pubkeys.map(pk =>
|
||||
pubkeyToName.get(pk) || shortHexPubkey(pk)
|
||||
);
|
||||
relays.push({
|
||||
url: relayUrl,
|
||||
status: poolRelay?.status || 0,
|
||||
connected: poolRelay ? poolRelay.status >= 5 : false,
|
||||
servingPubkeys: pubkeys,
|
||||
servingNames: names
|
||||
});
|
||||
}
|
||||
// Sort by serving count (most follows first), then by URL.
|
||||
relays.sort((a, b) => {
|
||||
const countDiff = b.servingPubkeys.length - a.servingPubkeys.length;
|
||||
if (countDiff !== 0) return countDiff;
|
||||
return a.url.localeCompare(b.url);
|
||||
});
|
||||
discoveredRelaysCache = relays;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have cached data, update the live connection status from the
|
||||
// pool without rebuilding the entire mapping.
|
||||
let relays = discoveredRelaysCache || [];
|
||||
if (relays.length > 0 && ndk.pool?.relays) {
|
||||
relays = relays.map(r => {
|
||||
const poolRelay = ndk.pool.relays.get(r.url);
|
||||
return {
|
||||
...r,
|
||||
status: poolRelay?.status || r.status || 0,
|
||||
connected: poolRelay ? poolRelay.status >= 5 : r.connected
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -6355,7 +6924,7 @@ self.onconnect = (event) => {
|
||||
|
||||
connectedPorts.push(port);
|
||||
|
||||
port.onmessage = async (e) => {
|
||||
port.onmessage = (e) => {
|
||||
const {
|
||||
type,
|
||||
pubkey,
|
||||
@@ -6390,16 +6959,18 @@ self.onconnect = (event) => {
|
||||
playlistIdentifier,
|
||||
ownerPubkey,
|
||||
playlistKind,
|
||||
eventIds
|
||||
eventIds,
|
||||
pubkeys,
|
||||
enabled
|
||||
} = e.data;
|
||||
|
||||
switch(type) {
|
||||
case 'init':
|
||||
await handleInit(pubkey, port);
|
||||
void handleInit(pubkey, port);
|
||||
break;
|
||||
|
||||
case 'subscribe':
|
||||
await handleSubscribe(subId, filters, opts, port);
|
||||
void handleSubscribe(subId, filters, opts, port);
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
@@ -6407,27 +6978,44 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'publish':
|
||||
await handlePublish(requestId, event, port);
|
||||
void handlePublish(requestId, event, port);
|
||||
break;
|
||||
|
||||
case 'publishRaw':
|
||||
await handlePublishRaw(requestId, event, port);
|
||||
void handlePublishRaw(requestId, event, port);
|
||||
break;
|
||||
|
||||
case 'publishRawToRelay':
|
||||
await handlePublishRawToRelay(requestId, event, relayUrl, port);
|
||||
void handlePublishRawToRelay(requestId, event, relayUrl, port);
|
||||
break;
|
||||
|
||||
case 'sendNip17Message':
|
||||
await handleSendNip17Message(requestId, recipientPubkey, content, port);
|
||||
void handleSendNip17Message(requestId, recipientPubkey, content, port);
|
||||
break;
|
||||
|
||||
case 'fetchEvents':
|
||||
await handleFetchEvents(requestId, filters, relayUrl, port);
|
||||
void handleFetchEvents(requestId, filters, relayUrl, port);
|
||||
break;
|
||||
|
||||
case 'ndkFetchEvents':
|
||||
await handleNdkFetchEvents(requestId, filters, port);
|
||||
void handleNdkFetchEvents(requestId, filters, port);
|
||||
break;
|
||||
|
||||
case 'warmOutbox':
|
||||
// Don't await — trackUsers involves network I/O to outbox pool
|
||||
// relays and would block the entire message dispatch loop,
|
||||
// starving getRelayData/getRelayStats messages.
|
||||
void handleWarmOutbox(requestId, pubkeys, port);
|
||||
break;
|
||||
|
||||
case 'setRelayEventLogging':
|
||||
void 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':
|
||||
@@ -6457,109 +7045,101 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'queryCache':
|
||||
await handleQueryCache(requestId, filters, port);
|
||||
void handleQueryCache(requestId, filters, port);
|
||||
break;
|
||||
|
||||
case 'purgePlaylistCache':
|
||||
await handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
|
||||
void handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
|
||||
break;
|
||||
|
||||
case 'syncMuteList': {
|
||||
try {
|
||||
const result = await loadWorkerMuteList(currentPubkey, { reason: 'sync-request' });
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: { success: true, result }
|
||||
case 'syncMuteList':
|
||||
void loadWorkerMuteList(currentPubkey, { reason: 'sync-request' })
|
||||
.then((result) => {
|
||||
port.postMessage({ type: 'response', requestId, data: { success: true, result } });
|
||||
})
|
||||
.catch((error) => {
|
||||
port.postMessage({ type: 'response', requestId, error: error?.message || String(error) });
|
||||
});
|
||||
} catch (error) {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
error: error?.message || String(error)
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'fetchCachedProfile':
|
||||
await handleFetchCachedProfile(requestId, e.data.pubkey, port);
|
||||
void handleFetchCachedProfile(requestId, e.data.pubkey, port);
|
||||
break;
|
||||
|
||||
case 'storeProfile':
|
||||
await handleStoreProfile(e.data.pubkey, e.data.profile);
|
||||
void handleStoreProfile(e.data.pubkey, e.data.profile);
|
||||
break;
|
||||
|
||||
case 'getAllCachedProfiles':
|
||||
await handleGetAllCachedProfiles(requestId, port);
|
||||
void handleGetAllCachedProfiles(requestId, port);
|
||||
break;
|
||||
|
||||
case 'getUserSettings':
|
||||
await handleGetUserSettings(requestId, port);
|
||||
void handleGetUserSettings(requestId, port);
|
||||
break;
|
||||
|
||||
case 'patchUserSettings':
|
||||
await handlePatchUserSettings(requestId, patch, options, port);
|
||||
void handlePatchUserSettings(requestId, patch, options, port);
|
||||
break;
|
||||
|
||||
case 'setUserSettings':
|
||||
await handleSetUserSettings(requestId, settings, options, port);
|
||||
void handleSetUserSettings(requestId, settings, options, port);
|
||||
break;
|
||||
|
||||
case 'resetUserSettings':
|
||||
await handleResetUserSettings(requestId, options, port);
|
||||
void handleResetUserSettings(requestId, options, port);
|
||||
break;
|
||||
|
||||
case 'encryptContent':
|
||||
await handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
|
||||
void handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
|
||||
break;
|
||||
|
||||
case 'decryptContent':
|
||||
await handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
|
||||
void handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
|
||||
break;
|
||||
|
||||
case 'walletPublishMintList':
|
||||
await handleWalletPublishMintList(requestId, relays, receiveMints, port);
|
||||
void handleWalletPublishMintList(requestId, relays, receiveMints, port);
|
||||
break;
|
||||
|
||||
case 'walletFetchMintList':
|
||||
await handleWalletFetchMintList(requestId, targetPubkey, port);
|
||||
void handleWalletFetchMintList(requestId, targetPubkey, port);
|
||||
break;
|
||||
|
||||
case 'walletInit':
|
||||
await handleWalletInit(requestId, port);
|
||||
void handleWalletInit(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletStart':
|
||||
await handleWalletStart(requestId, pubkey, port);
|
||||
void handleWalletStart(requestId, pubkey, port);
|
||||
break;
|
||||
|
||||
case 'walletCreate':
|
||||
await handleWalletCreate(requestId, mints, relays, port);
|
||||
void handleWalletCreate(requestId, mints, relays, port);
|
||||
break;
|
||||
|
||||
case 'walletHasWallet':
|
||||
await handleWalletHasWallet(requestId, port);
|
||||
void handleWalletHasWallet(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletGetBalance':
|
||||
await handleWalletGetBalance(requestId, port);
|
||||
void handleWalletGetBalance(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletGetMints':
|
||||
await handleWalletGetMints(requestId, port);
|
||||
void handleWalletGetMints(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletReceiveToken':
|
||||
await handleWalletReceiveToken(requestId, token, description, port);
|
||||
void handleWalletReceiveToken(requestId, token, description, port);
|
||||
break;
|
||||
|
||||
case 'walletSendToken':
|
||||
await handleWalletSendToken(requestId, amount, memo, mint, port);
|
||||
void handleWalletSendToken(requestId, amount, memo, mint, port);
|
||||
break;
|
||||
|
||||
case 'walletSendNutzap':
|
||||
await handleWalletSendNutzap(
|
||||
void handleWalletSendNutzap(
|
||||
requestId,
|
||||
amount,
|
||||
memo,
|
||||
@@ -6574,23 +7154,23 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'walletCreateDeposit':
|
||||
await handleWalletCreateDeposit(requestId, amount, mint, port);
|
||||
void handleWalletCreateDeposit(requestId, amount, mint, port);
|
||||
break;
|
||||
|
||||
case 'walletPayInvoice':
|
||||
await handleWalletPayInvoice(requestId, invoice, mint, port);
|
||||
void handleWalletPayInvoice(requestId, invoice, mint, port);
|
||||
break;
|
||||
|
||||
case 'walletAddMint':
|
||||
await handleWalletAddMint(requestId, mintUrl, port);
|
||||
void handleWalletAddMint(requestId, mintUrl, port);
|
||||
break;
|
||||
|
||||
case 'walletRemoveMint':
|
||||
await handleWalletRemoveMint(requestId, mintUrl, port);
|
||||
void handleWalletRemoveMint(requestId, mintUrl, port);
|
||||
break;
|
||||
|
||||
case 'walletGetTransactions':
|
||||
await handleWalletGetTransactions(requestId, port);
|
||||
void handleWalletGetTransactions(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletShutdown':
|
||||
@@ -6598,11 +7178,11 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'walletCheckProofs':
|
||||
await handleWalletCheckProofs(requestId, port);
|
||||
void handleWalletCheckProofs(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletRepublish':
|
||||
await handleWalletRepublish(requestId, port);
|
||||
void handleWalletRepublish(requestId, port);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -40,13 +40,19 @@
|
||||
|
||||
#divBackBar,
|
||||
#divHint,
|
||||
#divThread,
|
||||
#divComposer {
|
||||
#divThread {
|
||||
width: 80%;
|
||||
min-width: 300px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
/* #divComposer lives INSIDE #divThread (already constrained to 700px),
|
||||
so it must fill its parent (100%) rather than re-applying 80%
|
||||
which would shrink it to 80% of 700 = 560px. */
|
||||
#divComposer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#divBackBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -109,6 +115,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topPostComposerInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 6em;
|
||||
padding: 8px;
|
||||
line-height: 1.4;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.divPostItem .post-composer-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -125,6 +141,34 @@
|
||||
margin-bottom: 12px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THREADED REPLIES — vertical indent lines (Phase 2.5)
|
||||
All colors via CSS variables — never hardcoded.
|
||||
============================================================ */
|
||||
.reply-thread-container {
|
||||
position: relative;
|
||||
padding-left: 12px; /* per-level indent is applied inline */
|
||||
}
|
||||
|
||||
.reply-thread-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reply-thread-line.selected {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
.reply-item-focused {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -141,15 +185,15 @@
|
||||
|
||||
<div id="divBody">
|
||||
<div id="divBackBar">
|
||||
<a id="btnBack" href="./feed2.html" title="Back to feed">← Feed</a>
|
||||
<a id="btnBack" href="./feed.html" title="Back to feed">← Feed</a>
|
||||
</div>
|
||||
<div id="divHint">Loading thread…</div>
|
||||
<div id="divThread">
|
||||
<div id="divOriginalPost"></div>
|
||||
<div id="divComposer"></div>
|
||||
<div id="divThreadHeader">Replies</div>
|
||||
<div id="divReplies"></div>
|
||||
</div>
|
||||
<div id="divComposer"></div>
|
||||
</div>
|
||||
|
||||
<div id="divFooter">
|
||||
@@ -216,6 +260,8 @@
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -290,6 +336,7 @@
|
||||
const replies = []; // flat list of reply events
|
||||
const replyIds = new Set(); // dedupe by event id
|
||||
const renderedReplyIds = new Set();
|
||||
const focusedEventId = (urlParams.get('focus') || '').trim().toLowerCase() || null;
|
||||
|
||||
let hamburgerInstance = null;
|
||||
let logoutHamburger = null;
|
||||
@@ -457,6 +504,8 @@
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -539,14 +588,18 @@
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
REPLY THREAD RENDERING (flat, chronological ascending)
|
||||
REPLY THREAD RENDERING — threaded with vertical indent lines
|
||||
(Phase 2.5 — matches Amethyst's thread view)
|
||||
================================================================ */
|
||||
function isReplyToThread(evt) {
|
||||
if (!evt?.tags) return false;
|
||||
const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]);
|
||||
if (eTags.length === 0) return false;
|
||||
// A reply is any kind 1 that references the target event id in an e tag.
|
||||
return eTags.some((t) => t[1] === targetEventId);
|
||||
// A reply is any kind 1 that references the root post OR any already-known
|
||||
// reply in the thread (nested replies only carry their immediate parent,
|
||||
// not the root post id). This keeps the thread inclusive of replies-to-replies.
|
||||
if (eTags.some((t) => t[1] === targetEventId)) return true;
|
||||
return eTags.some((t) => replyIds.has(t[1]));
|
||||
}
|
||||
|
||||
function upsertReply(evt) {
|
||||
@@ -559,10 +612,197 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderReplies() {
|
||||
replies.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
||||
/* ----------------------------------------------------------------
|
||||
NIP-10 parent extraction
|
||||
----------------------------------------------------------------
|
||||
Returns the direct parent event id for a reply event, per NIP-10:
|
||||
- The last `e` tag with marker "reply" is the direct parent.
|
||||
- If no marked "reply" tag, fall back to the last unmarked `e`
|
||||
tag (legacy NIP-10).
|
||||
- `e` tags with marker "root" or "mention" are NOT the parent.
|
||||
Returns null if no parent can be determined.
|
||||
---------------------------------------------------------------- */
|
||||
function getDirectParentId(evt) {
|
||||
if (!evt?.tags) return null;
|
||||
const eTags = evt.tags.filter((t) => t[0] === 'e' && t[1]);
|
||||
if (eTags.length === 0) return null;
|
||||
|
||||
// Re-render the whole reply list (flat thread, simplest & most reliable).
|
||||
// Prefer the last e tag explicitly marked "reply".
|
||||
let lastReply = null;
|
||||
let lastUnmarked = null;
|
||||
for (const t of eTags) {
|
||||
const marker = t[3];
|
||||
if (marker === 'reply') {
|
||||
lastReply = t[1];
|
||||
} else if (marker === 'root' || marker === 'mention') {
|
||||
// not a direct parent
|
||||
} else {
|
||||
// unmarked — legacy NIP-10, last one is the parent
|
||||
lastUnmarked = t[1];
|
||||
}
|
||||
}
|
||||
if (lastReply) return lastReply;
|
||||
if (lastUnmarked) return lastUnmarked;
|
||||
|
||||
// All e tags are root/mention markers — no direct parent identifiable.
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
computeReplyLevels(events, rootEventId)
|
||||
----------------------------------------------------------------
|
||||
Builds eventId -> parentEventId map from NIP-10 e tags, then
|
||||
computes a level (depth) for each event by walking up the parent
|
||||
chain to the root. Returns a Map<eventId, level>.
|
||||
|
||||
- Root post (id === rootEventId, or no parent) -> level 0
|
||||
- Direct reply to root -> level 1
|
||||
- Reply to a level-N reply -> level N+1
|
||||
- Missing parent (not in the fetched set) -> treated as level 1
|
||||
- Cycle protection: cap at a sane depth (256) to avoid infinite
|
||||
loops on malformed events.
|
||||
---------------------------------------------------------------- */
|
||||
function computeReplyLevels(events, rootEventId) {
|
||||
const byId = new Map();
|
||||
for (const evt of events) {
|
||||
if (evt?.id) byId.set(evt.id, evt);
|
||||
}
|
||||
if (rootEventId && originalPost && !byId.has(rootEventId)) {
|
||||
byId.set(rootEventId, originalPost);
|
||||
}
|
||||
|
||||
const parentOf = new Map(); // eventId -> parentEventId | null
|
||||
for (const evt of events) {
|
||||
if (!evt?.id) continue;
|
||||
if (evt.id === rootEventId) {
|
||||
parentOf.set(evt.id, null);
|
||||
continue;
|
||||
}
|
||||
const pid = getDirectParentId(evt);
|
||||
parentOf.set(evt.id, pid || null);
|
||||
}
|
||||
|
||||
const levels = new Map();
|
||||
const MAX_DEPTH = 256;
|
||||
|
||||
function resolveLevel(eventId, seen) {
|
||||
if (levels.has(eventId)) return levels.get(eventId);
|
||||
if (eventId === rootEventId) {
|
||||
levels.set(eventId, 0);
|
||||
return 0;
|
||||
}
|
||||
if (seen.has(eventId)) {
|
||||
// cycle — treat as level 1 to break the loop
|
||||
levels.set(eventId, 1);
|
||||
return 1;
|
||||
}
|
||||
seen.add(eventId);
|
||||
|
||||
const pid = parentOf.get(eventId);
|
||||
if (!pid) {
|
||||
// No parent identifiable — treat as a direct reply to root.
|
||||
levels.set(eventId, 1);
|
||||
return 1;
|
||||
}
|
||||
if (!byId.has(pid)) {
|
||||
// Parent not in the fetched set — treat as direct reply (level 1).
|
||||
levels.set(eventId, 1);
|
||||
return 1;
|
||||
}
|
||||
const parentLevel = resolveLevel(pid, seen);
|
||||
const lvl = Math.min(parentLevel + 1, MAX_DEPTH);
|
||||
levels.set(eventId, lvl);
|
||||
return lvl;
|
||||
}
|
||||
|
||||
for (const evt of events) {
|
||||
if (!evt?.id) continue;
|
||||
if (!levels.has(evt.id)) {
|
||||
resolveLevel(evt.id, new Set());
|
||||
}
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Depth-first ordering
|
||||
----------------------------------------------------------------
|
||||
Builds a parent -> children map and traverses depth-first,
|
||||
children sorted chronologically (oldest first). The root's
|
||||
children come first, then each child's subtree before moving
|
||||
to the next sibling. This makes vertical lines visually
|
||||
contiguous — a reply's descendants appear directly below it.
|
||||
|
||||
Returns an array of { event, level } in display order.
|
||||
---------------------------------------------------------------- */
|
||||
function buildDepthFirstOrder(events, levels, rootEventId) {
|
||||
const childrenOf = new Map(); // parentId -> [event, ...]
|
||||
|
||||
for (const evt of events) {
|
||||
if (!evt?.id || evt.id === rootEventId) continue;
|
||||
const pid = getDirectParentId(evt);
|
||||
let parentKey;
|
||||
if (pid && events.some((e) => e?.id === pid)) {
|
||||
parentKey = pid;
|
||||
} else {
|
||||
// Parent not in set — group under root.
|
||||
parentKey = rootEventId;
|
||||
}
|
||||
if (!childrenOf.has(parentKey)) childrenOf.set(parentKey, []);
|
||||
childrenOf.get(parentKey).push(evt);
|
||||
}
|
||||
|
||||
// Sort each child group chronologically (oldest first).
|
||||
for (const arr of childrenOf.values()) {
|
||||
arr.sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
|
||||
}
|
||||
|
||||
const ordered = [];
|
||||
const visited = new Set();
|
||||
|
||||
function visit(parentId, level) {
|
||||
const kids = childrenOf.get(parentId) || [];
|
||||
for (const child of kids) {
|
||||
if (visited.has(child.id)) continue;
|
||||
visited.add(child.id);
|
||||
const childLevel = levels.get(child.id) ?? level + 1;
|
||||
ordered.push({ event: child, level: childLevel });
|
||||
visit(child.id, childLevel);
|
||||
}
|
||||
}
|
||||
|
||||
visit(rootEventId, 0);
|
||||
|
||||
// Debug check: make sure every event in the input set appears in
|
||||
// the ordered list. If any are missing (e.g. an orphaned reply
|
||||
// whose parent is not in the fetched set but was somehow not
|
||||
// grouped under root), log a warning and append them at the end
|
||||
// so no reply is silently dropped.
|
||||
const orderedIds = new Set(ordered.map((o) => o.event?.id));
|
||||
const missing = [];
|
||||
for (const evt of events) {
|
||||
if (!evt?.id || evt.id === rootEventId) continue;
|
||||
if (!orderedIds.has(evt.id)) {
|
||||
missing.push(evt);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
console.warn(
|
||||
`[post-feed.html] ${missing.length} reply event(s) missing from depth-first order — appending at end:`,
|
||||
missing.map((e) => e.id)
|
||||
);
|
||||
for (const evt of missing) {
|
||||
ordered.push({ event: evt, level: levels.get(evt.id) ?? 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
renderReplies() — threaded rendering with vertical indent lines
|
||||
---------------------------------------------------------------- */
|
||||
function renderReplies() {
|
||||
divReplies.innerHTML = '';
|
||||
renderedReplyIds.clear();
|
||||
|
||||
@@ -572,21 +812,65 @@
|
||||
return;
|
||||
}
|
||||
|
||||
divThreadHeader.textContent = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
||||
const levels = computeReplyLevels(replies, targetEventId);
|
||||
const ordered = buildDepthFirstOrder(replies, levels, targetEventId);
|
||||
|
||||
replies.forEach((reply) => {
|
||||
const totalLabel = `${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}`;
|
||||
divThreadHeader.textContent = totalLabel;
|
||||
|
||||
const wiredIds = [];
|
||||
|
||||
ordered.forEach(({ event: reply, level }) => {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'reply-thread-container';
|
||||
container.dataset.replyId = reply.id;
|
||||
container.dataset.level = String(level);
|
||||
// Per-level left padding so the post content sits to the right of
|
||||
// all the vertical lines for its ancestors.
|
||||
container.style.paddingLeft = `${level * 12}px`;
|
||||
|
||||
// Vertical indent lines: one per ancestor level (0..level-1).
|
||||
// The last line (level-1, closest to the post) uses the accent
|
||||
// color if this is the focused post; all others use muted.
|
||||
const isFocused = focusedEventId && reply.id === focusedEventId;
|
||||
for (let i = 0; i < level; i += 1) {
|
||||
const line = document.createElement('div');
|
||||
line.className = 'reply-thread-line';
|
||||
// Position each line at left = i * 12px (within the container's
|
||||
// padding box). The container has paddingLeft = level*12, so the
|
||||
// lines sit in the padded gutter.
|
||||
line.style.left = `${i * 12}px`;
|
||||
if (isFocused && i === level - 1) {
|
||||
line.classList.add('selected');
|
||||
}
|
||||
container.appendChild(line);
|
||||
}
|
||||
|
||||
// The post card itself.
|
||||
const replyEl = cards.createCard(reply, { currentPubkey, autoWire: false, autoplayVideo: false });
|
||||
divReplies.appendChild(replyEl);
|
||||
replyEl.dataset.threadLevel = String(level);
|
||||
if (isFocused) {
|
||||
replyEl.classList.add('reply-item-focused');
|
||||
}
|
||||
container.appendChild(replyEl);
|
||||
|
||||
divReplies.appendChild(container);
|
||||
renderedReplyIds.add(reply.id);
|
||||
wiredIds.push(reply.id);
|
||||
});
|
||||
|
||||
cards.wireInteractions(replies.map((r) => r.id), { currentPubkey });
|
||||
cards.wireInteractions(wiredIds, { currentPubkey });
|
||||
divFooterRight.textContent = `${1 + replies.length} posts`;
|
||||
}
|
||||
|
||||
function appendReplyIfNew(evt) {
|
||||
if (!upsertReply(evt)) return;
|
||||
renderReplies();
|
||||
// If a focus param was provided, scroll the focused post into view.
|
||||
if (focusedEventId) {
|
||||
const focusEl = divReplies.querySelector(`.reply-thread-container[data-reply-id="${focusedEventId}"]`);
|
||||
if (focusEl) {
|
||||
requestAnimationFrame(() => {
|
||||
focusEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
@@ -698,27 +982,104 @@
|
||||
});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
fetchRepliesRecursive(rootEventId, maxDepth)
|
||||
----------------------------------------------------------------
|
||||
Recursively fetches replies to the root post AND replies to those
|
||||
replies (nested replies), up to maxDepth levels. Nested replies
|
||||
only carry their immediate parent in their `e` tags — not the root
|
||||
post id — so a single { '#e': [rootId] } query misses them.
|
||||
|
||||
Strategy (breadth-first by depth level):
|
||||
depth 0: fetch replies to rootEventId
|
||||
depth 1: fetch replies to every reply found at depth 0
|
||||
depth 2: fetch replies to every reply found at depth 1
|
||||
... up to maxDepth
|
||||
|
||||
The `#e` filter accepts an array of ids, so we batch all ids at
|
||||
the current depth into a single fetch. Dedup via replyIds so a
|
||||
reply discovered via multiple parent paths is only added once.
|
||||
|
||||
Both the cache (queryCache) and relays (ndkFetchEvents) are
|
||||
consulted at each level — cache first for fast display, then
|
||||
relays for hydration. A per-level timeout guards against
|
||||
excessive relay requests on very long threads.
|
||||
---------------------------------------------------------------- */
|
||||
const REPLY_FETCH_MAX_DEPTH = 5;
|
||||
const REPLY_FETCH_LEVEL_TIMEOUT_MS = 8000;
|
||||
|
||||
function fetchRepliesForLevelFromCache(parentIds) {
|
||||
return queryCache({ kinds: [1], '#e': parentIds }).catch(() => []).then((r) => Array.isArray(r) ? r : []);
|
||||
}
|
||||
|
||||
function fetchRepliesForLevelFromRelays(parentIds) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve([]);
|
||||
}, REPLY_FETCH_LEVEL_TIMEOUT_MS);
|
||||
|
||||
ndkFetchEvents({ kinds: [1], '#e': parentIds })
|
||||
.then((events) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(Array.isArray(events) ? events : []);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[post-feed.html] Recursive reply level fetch failed:', error?.message || error);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve([]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRepliesRecursive(rootEventId, maxDepth = REPLY_FETCH_MAX_DEPTH) {
|
||||
if (!rootEventId) return;
|
||||
|
||||
let currentLevelIds = [rootEventId];
|
||||
|
||||
for (let depth = 0; depth < maxDepth; depth += 1) {
|
||||
if (currentLevelIds.length === 0) break;
|
||||
|
||||
// Cache first — fast display of whatever we already have.
|
||||
const cachedEvents = await fetchRepliesForLevelFromCache(currentLevelIds);
|
||||
const nextLevelIds = [];
|
||||
for (const evt of cachedEvents) {
|
||||
if (upsertReply(evt)) {
|
||||
nextLevelIds.push(evt.id);
|
||||
}
|
||||
}
|
||||
if (cachedEvents.length > 0) renderReplies();
|
||||
|
||||
// Then relay hydration for this level.
|
||||
const relayEvents = await fetchRepliesForLevelFromRelays(currentLevelIds);
|
||||
for (const evt of relayEvents) {
|
||||
if (upsertReply(evt)) {
|
||||
nextLevelIds.push(evt.id);
|
||||
}
|
||||
}
|
||||
if (relayEvents.length > 0) renderReplies();
|
||||
|
||||
// Move to the next depth level: replies to the replies we just found.
|
||||
// (upsertReply dedupes via replyIds, so ids already seen are skipped
|
||||
// on the next iteration by virtue of not being newly added — but we
|
||||
// still need to query for their children, so include all newly-found
|
||||
// ids here.)
|
||||
currentLevelIds = nextLevelIds;
|
||||
}
|
||||
|
||||
// Final render to ensure consistency.
|
||||
renderReplies();
|
||||
}
|
||||
|
||||
async function fetchReplies() {
|
||||
if (!targetEventId) return;
|
||||
|
||||
// Cache first.
|
||||
try {
|
||||
const cachedReplies = await queryCache({ kinds: [1], '#e': [targetEventId] });
|
||||
if (Array.isArray(cachedReplies)) {
|
||||
cachedReplies.forEach((evt) => upsertReply(evt));
|
||||
renderReplies();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Then relay fetch.
|
||||
void ndkFetchEvents({ kinds: [1], '#e': [targetEventId] }).then((fresh) => {
|
||||
if (Array.isArray(fresh)) {
|
||||
fresh.forEach((evt) => upsertReply(evt));
|
||||
}
|
||||
renderReplies();
|
||||
}).catch((error) => {
|
||||
console.warn('[post-feed.html] Reply fetch failed:', error?.message || error);
|
||||
});
|
||||
await fetchRepliesRecursive(targetEventId, REPLY_FETCH_MAX_DEPTH);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
@@ -887,7 +1248,16 @@
|
||||
}
|
||||
|
||||
if (isReplyToThread(evt)) {
|
||||
appendReplyIfNew(evt);
|
||||
const wasNew = upsertReply(evt);
|
||||
if (wasNew) {
|
||||
renderReplies();
|
||||
// A newly-seen reply may itself have nested replies we haven't
|
||||
// fetched yet. Trigger a one-level-deep fetch for replies to it
|
||||
// so nested replies appear in realtime too.
|
||||
fetchRepliesRecursive(evt.id, 1).catch((err) => {
|
||||
console.warn('[post-feed.html] Realtime nested fetch failed:', err?.message || err);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
182
www/relays.html
182
www/relays.html
@@ -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:100%;margin:10px 0 0 0;overflow-x: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;max-height:300px;overflow-y:auto;"></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,112 @@ 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;
|
||||
}
|
||||
|
||||
// Filter out relays with no serving pubkeys — if no follows use this
|
||||
// relay, there's no reason to show it in the discovered relays list.
|
||||
const relaysWithFollows = relays.filter(r =>
|
||||
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
|
||||
);
|
||||
|
||||
if (relaysWithFollows.length === 0) {
|
||||
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. 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;">Follows</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const relay of relaysWithFollows) {
|
||||
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
|
||||
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
|
||||
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
|
||||
? relay.servingNames.join(', ')
|
||||
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.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;word-break:break-all;">${followsList}</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 +1551,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 +1582,9 @@ const versionInfo = await getVersion();
|
||||
|
||||
await loadCurrentDmInboxRelayList();
|
||||
|
||||
// Initialize discovered relays (outbox) collapsible toggle
|
||||
initDiscoveredRelaysToggle();
|
||||
|
||||
// Get initial relay data
|
||||
await refreshRelayData();
|
||||
|
||||
@@ -1444,7 +1596,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);
|
||||
|
||||
Reference in New Issue
Block a user