Compare commits

...

32 Commits

Author SHA1 Message Date
Laan Tungir
7edee38ea1 Add Like/Zap/Nutzap/Comment/Quote to notification dropdown menu via hidden interaction bar 2026-07-01 15:56:16 -04:00
Laan Tungir
1a17bef1ac Implement Phase 1 remaining tasks: notifications append-only, listener leak fix, memory caps
Task 1.2: Append-only rendering for notifications page
- Extracted buildNotificationRow(item) helper from renderNotifications()
- Added prependNotificationRow(item) with sorted insertion by created_at
- addNotificationFromEvent uses prepend when DOM has children

Task 1.3: Fix notification document.click listener leak
- Removed per-row document.addEventListener('click') that leaked on every render
- Added single delegated click listener in main() using .notif-menu-panel class
- Menu panels still close when clicking outside .notif-right

Task 1.4: Cap posts[] and postIds with rolling eviction
- Added MAX_STORED_POSTS=500 constant to feed.html and post.html
- Added prunePostsArray() that sorts, slices, rebuilds postIds/renderedPostIds
- Called in upsertFeedPost/upsertPost with +50 batch threshold
- post.html skips pruning in event mode

Task 1.5: Cap notificationsById/unreadNotificationsById with periodic pruning
- Added MAX_STORED_NOTIFICATIONS=500, MAX_POST_IMAGE_CACHE=200
- Added pruneNotificationMaps() that prunes both Maps and LRU-caps postImageCache
- Called in addNotificationFromEvent, hydrateNotificationsFromCache/Relays

Updated plan checklist marking all Phase 1 tasks complete.
2026-07-01 09:33:29 -04:00
Laan Tungir
04405da933 Fix feed page dropped posts: out-of-order events and race condition
prependPostCard() now inserts posts in the correct sorted position within
the existing DOM instead of falling back to a full renderFeed() rebuild
when a post is older than the first visible post. This handles relays
that deliver events out of order without wiping the feed.

Also fixed a race condition in fetchFeedWindow: the fire-and-forget
ndkFetchEvents callback was calling renderFeed() after bootstrap
completed, which wiped prepended live posts and caused concurrent
rebuild races. Now it only calls renderFeed() if the feed hasn't been
rendered yet (renderedPostIds.size === 0).

Same prependPostCard sorted-insert fix applied to post.html.
2026-07-01 09:22:33 -04:00
Laan Tungir
ee0077063d Implement append-only rendering for feed and post pages (task 1.1)
Split renderFeed() into full rebuild + prependPostCard() on both feed.html
and post.html. New live events now prepend a single card at the top of the
feed without rebuilding existing cards, eliminating O(n) DOM rebuilds on
every incoming event. DOM is trimmed to displayCount on overflow with
renderedPostIds kept in sync. Full rebuild path preserved for See More,
mute removal, bootstrap completion, and event mode.

Also added implementation checklist to plans/long-running-memory-bloat-fix.md.
2026-07-01 07:59:39 -04:00
Laan Tungir
b09bf0d839 Fix ReferenceError: move publishStartTime to outer scope so silent failure detection can access it 2026-06-30 11:36:36 -04:00
Laan Tungir
d04844cce8 Detect silent relay failures: after each batch, mark relays with no response as timed out (catches DNS errors, connection refused, HTTP handshake failures that NDK doesn't emit relay:publish:failed for) 2026-06-30 11:24:45 -04:00
Laan Tungir
b03b848909 Add debug logging: per-relay publish/timeout/reject with elapsed time, batch start/end timing 2026-06-30 11:07:27 -04:00
Laan Tungir
9b746838c3 Fix batch info in footer: include batchCurrent/batchTotal in per-relay progress events, set currentBatch before each batch starts 2026-06-30 10:55:04 -04:00
Laan Tungir
978e5029f3 Skip-mark timed-out broadcast relays too (not just explicit rejections) — reason distinguishes 'publish-timeout' from 'publish-rejected' 2026-06-30 10:42:12 -04:00
Laan Tungir
011e4c303c Show batch progress in footer: '📡 50/630 relays · 2/25 batches · relay.example.com' 2026-06-30 10:36:42 -04:00
Laan Tungir
fa019fe9a1 Send publish response after outbox relays complete, then run broadcast batches in background — composer clears immediately while broadcast continues 2026-06-30 10:33:14 -04:00
Laan Tungir
0f31e1c301 Batched broadcast publishing: split 600+ relays into batches of 25, publish sequentially to avoid browser WebSocket connection limits. 10s per-relay timeout per batch. 2026-06-30 10:21:55 -04:00
Laan Tungir
c9a20e63b5 Keep broadcast result in footer until next publish overwrites it (remove 10s auto-clear) 2026-06-30 10:13:46 -04:00
Laan Tungir
79e38b8f79 Experiment: increase per-relay broadcast timeout to 60s to test if NDK can handle 600 relays with longer connection time 2026-06-30 10:11:08 -04:00
Laan Tungir
7cfd43b91b Set per-relay broadcast timeout to 5s, overall deadline to totalRelays*5s (min 30s). NDK runs relays in parallel so 600 relays finish in ~5s not 3000s 2026-06-30 10:06:37 -04:00
Laan Tungir
777ab312a5 Fix broadcast timeout: increase connection timeout to 15s for temporary relays, race publish against 45s overall deadline so one slow relay doesn't block 2026-06-30 10:02:00 -04:00
Laan Tungir
2763dd9d6a Add relay URL hover tooltip on post time: shows 'Published to N relays:' with URL list when hovering over the relay count 2026-06-30 09:37:36 -04:00
Laan Tungir
a20b28cd21 Fix relay count display: emit broadcastProgress done event for all publishes (not just broadcasts), add missing eventId/eventKind to broadcast done event 2026-06-30 09:33:08 -04:00
Laan Tungir
456f0e1f2e Add relay count to post time display: shows '21r - 1h' format on each post card, tracking successful relay count from broadcastProgress events 2026-06-30 09:28:38 -04:00
Laan Tungir
4abb4bf33c Add 10-sat fee-reserve buffer to handleWalletPayInvoice melt to fix 'not enough inputs provided for melt' errors 2026-06-30 09:19:30 -04:00
Laan Tungir
7dcfc7c3e3 Fix broadcast relay timeout: use 30s timeout for large broadcasts, distinguish timeouts from explicit rejections, only skip-mark explicit rejections, use live-tracked sets for results 2026-06-30 09:15:02 -04:00
Laan Tungir
07f5f39f35 Add per-mint tracing logs to handleWalletPayInvoice for lightning zap diagnostics 2026-06-30 07:59:52 -04:00
Laan Tungir
10ddda633c Add broadcast relays feature: kind 10088 list with always-on union, persistent skip-marks, live progress in post.html footer, sidenav management UI on relays.html 2026-06-30 07:47:22 -04:00
Laan Tungir
c55a5ebfd8 Self-host GPT-2 model files to fix CORS over Tor/onion origins 2026-06-28 20:46:57 -04:00
Laan Tungir
b6e945f8de Use locally-hosted GPT-2 model to fix CORS over Tor/onion origins 2026-06-28 20:45:25 -04:00
Laan Tungir
3ec585273f Change 'post on Primal' to 'post on Nostr' in inspiration FAQ 2026-06-28 20:32:50 -04:00
Laan Tungir
0e8229e13e Update inspiration FAQ to reference waxwing's Primal post and the Meteor paper 2026-06-28 20:30:26 -04:00
Laan Tungir
230bd70e07 Change all muted/dimmed text on stego page to regular primary color 2026-06-28 20:27:13 -04:00
Laan Tungir
c36cb4a0fe Replace simple example FAQ with detailed toy-model walkthrough from llm_steganography_explained.md 2026-06-28 20:25:51 -04:00
Laan Tungir
d6b167a50b Expand How It Works into FAQ section with collapsible questions 2026-06-28 20:23:01 -04:00
Laan Tungir
27f4cd6857 Set llm-steganography page to no-login (auth mode none) by default 2026-06-28 20:18:18 -04:00
Laan Tungir
28c8109f8a Add LLM Steganography demo page with GPT-2-based half-splitting entropy coding 2026-06-28 20:17:04 -04:00
20 changed files with 11016 additions and 214 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# GPT-2 model files (271MB, too large for git — uploaded directly to server)
www/models/
hamburger_morphing/
client/
ndk/

View File

@@ -0,0 +1,122 @@
# Broadcast Relay Batching Fix
## Problem
With 600+ broadcast relays, NDK's `fromRelayUrls` creates 630 temporary relays
and adds them all to the pool simultaneously. `NDKRelaySet.publish()` then fires
all 630 relay publish operations in parallel via `Promise.all`. Each relay that
isn't connected calls `relay.connect()` which opens a WebSocket.
Browsers limit simultaneous WebSocket connections (practically ~50-100 at a
time before connections queue up). With 630 simultaneous connection attempts,
most relays never get a connection slot before the per-relay timeout expires.
Result: only ~13/630 relays succeed.
## Solution: Chunked publishing
Instead of creating one giant `NDKRelaySet` with all 630 relays and calling
`publish()` once, we split the relay URLs into **batches of 50** and publish
to each batch sequentially. Each batch gets its own `NDKRelaySet.fromRelayUrls()`
call, its own `publish()`, and its own set of live progress listeners.
```mermaid
flowchart TD
A[630 relay URLs] --> B[Split into batches of 50]
B --> C[Batch 1: 50 relays]
C --> D[publish to batch 1]
D --> E{Batch 1 done?}
E -->|yes| F[Batch 2: 50 relays]
F --> G[publish to batch 2]
G --> H{Batch 2 done?}
H -->|yes| I[... continue ...]
I --> J[Batch 13: 30 relays]
J --> K[publish to batch 13]
K --> L{All batches done?}
L -->|yes| M[Final done event with total count]
```
### Key design points
1. **Batch size: 50 relays** — small enough to stay within browser WebSocket
limits, large enough to finish in a reasonable time. Each batch takes ~5-10s.
2. **Sequential batches** — wait for each batch to complete before starting the
next. This keeps the total simultaneous connections at ~50, not 630.
3. **Live progress across batches** — the `publishedSoFar` and `failedSoFar`
sets accumulate across all batches. The `broadcastProgress` events stream
continuously, so the footer shows the count climbing from 0 to 630 across
all batches.
4. **Per-relay timeout: 10s per batch** — each relay in a batch gets 10s to
connect + publish. Since only 50 are competing for connections (not 630),
this is plenty of time.
5. **Overall deadline: removed** — since batches are sequential and each has
its own per-relay timeout, the overall time is naturally bounded at
`numBatches × perBatchTimeout`. No need for a separate overall deadline.
6. **Connection timeout: 10s** — set on each relay in the batch, matching the
publish timeout.
## Implementation (in `www/ndk-worker.js` `handlePublish`)
Replace the current single `NDKRelaySet.fromRelayUrls(allUrls, ndk)` + single
`publish()` with a batched loop:
```js
const BATCH_SIZE = 50;
const PER_RELAY_TIMEOUT_MS = 10000;
if (isBroadcast) {
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
const batches = [];
for (let i = 0; i < allUrls.length; i += BATCH_SIZE) {
batches.push(allUrls.slice(i, i + BATCH_SIZE));
}
console.log(`[Worker] Broadcasting to ${allUrls.length} relays in ${batches.length} batches of ${BATCH_SIZE}`);
// Emit start event
broadcast({ type: 'broadcastProgress', sessionId, eventId, eventKind, phase: 'start', total: allUrls.length, successful: 0, failed: 0, latestRelay: null });
// Attach listeners once — they accumulate across all batches
ndkEvent.on('relay:published', (relay) => { ... publishedSoFar.add ... });
ndkEvent.on('relay:publish:failed', (relay, err) => { ... failedSoFar.add ... });
// Publish to each batch sequentially
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
// Set connection timeout on each relay in this batch
for (const relay of batchSet.relays) {
try { relay.connectionTimeout = PER_RELAY_TIMEOUT_MS; } catch (_) {}
}
try {
await ndkEvent.publish(batchSet, PER_RELAY_TIMEOUT_MS, 1);
} catch (e) {
// Expected — some relays in this batch may fail
}
console.log(`[Worker] Batch ${i+1}/${batches.length} done: ${publishedSoFar.size}/${allUrls.length} total succeeded`);
}
// Emit final done event
broadcast({ type: 'broadcastProgress', sessionId, eventId, eventKind, phase: 'done', total: allUrls.length, successful: publishedSoFar.size, failed: failedSoFar.size + timedOutSoFar.size, relayUrls: Array.from(publishedSoFar) });
// Clean up listeners
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
}
```
## Files touched
| File | Change |
|------|--------|
| [`www/ndk-worker.js`](www/ndk-worker.js) | Replace single publish with batched loop in `handlePublish` |
## Testing
1. Post with a user that has 600+ broadcast relays
2. Watch the footer: count should climb steadily as batches complete
3. Final count should be much higher than 13 (ideally 500+)
4. Each batch of 50 should take ~5-10 seconds
5. Total time for 630 relays: ~13 batches × ~10s = ~130 seconds

View File

@@ -0,0 +1,713 @@
# Broadcast Relays Feature
## Goal
Allow the user to publish posts to **many more relays** than their standard
NIP-65 outbox — potentially hundreds — by maintaining a separate "broadcast
relay list" that is **unioned into every public publish** automatically.
This mirrors Amethyst's `BroadcastRelayListEvent` (NIP-51 kind 10088) and uses
NDK's `NDKRelaySet.fromRelayUrls()` primitive, which the worker already
exercises in `handlePublishRawToRelay`.
## Design decisions (confirmed with user)
1. **Always-on union** — broadcast relays are automatically unioned into every
public publish. No per-post toggle. (Matches Amethyst's default.)
2. **Manage on `relays.html`** — a new "Broadcast Relays" section alongside
"Discovered Relays" for add/remove/save.
3. **One-click seed from discovered relays** — a button on `relays.html` bulk-
adds the discovered-relays URLs (from follows' kind 10002) to the 10088
list. User stays in control of when it happens.
4. **Persistent skip-marks in kind 10088** — when a relay fails to publish
(auth-required, unreachable, etc.), it stays in the list but is marked with
a separate `["x", url]` tag so we skip it on future publishes. See
"Skip-mark coexistence with Amethyst" below.
## Background — what each codebase already does
### Amethyst (`~/lt/amethyst`)
- [`BroadcastRelayListEvent.kt`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt:43)
defines **kind 10088** — a NIP-51 `PrivateTagArrayEvent`. Relay URLs live as
`["r", url]` tags inside a NIP-44-encrypted `content` blob (private list),
with an optional public-tag variant. It is replaceable per pubkey
(`d = ""`).
- [`AccountOutboxRelayState.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt:41)
**unions** the broadcast set into the outbox:
`nip65Outbox + privateOutBox + localRelays + broadcastRelays`.
- [`Account.kt:1266`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1266)
gates inclusion with `wantsBroadcastRelays(event)` — public posts/reactions/
reposts get the broadcast set; drafts, app settings, bookmarks, channels with
their own relays, and DMs do **not**.
- [`Account.kt:1715`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1715)
`sendLiterallyEverywhere()` is a separate "publish to everything" path used
only for profile/identity bootstrap events — **not** what we want for posts.
### This client (`/home/user/lt/client`)
- [`publishEvent`](www/js/init-ndk.mjs:629) → worker
[`handlePublish`](www/ndk-worker.js:5810) → `ndkEvent.publish()` with **no
relaySet** → NDK outbox routing sends only to the user's kind 10002 write
relays.
- [`publishRawEventToRelay`](www/js/init-ndk.mjs:688) → worker
[`handlePublishRawToRelay`](www/ndk-worker.js:5983) already demonstrates the
exact NDK primitive we need:
```js
NDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk)
await ndkEvent.publish(targetRelaySet)
```
NDK's `fromRelayUrls` (see `ndk-core.bundle.js:9245`) accepts an **array**
and uses **temporary relays** (`pool.useTemporaryRelay`) so hundreds of URLs
work without permanently polluting the connection pool.
- [`mute-list.mjs`](www/js/mute-list.mjs:41) establishes the
NIP-44-encrypted-private-list pattern we will mirror:
`configureMuteList({ nip44Encrypt, nip44Decrypt, publishEvent, getPubkey })`.
- The post composer ([`post-composer.mjs:740`](www/js/post-composer.mjs:740))
calls `onSubmit(text, {attachments})` → pages call
`publishEvent({kind:1,...})`. No composer change is needed for the always-on
design.
- No kind 10088 / broadcast-relay concept exists yet.
## Skip-mark coexistence with Amethyst
Amethyst's [`RelayTag`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt:28)
only matches `tag[0] == "r"` and reads `tag[1]` — it **ignores** any third
element. Its `updateRelayList` calls `tags.remove(RelayTag::match)` which only
strips `r` tags, **preserving** any non-`r` tags through Amethyst's updates.
This means we can safely store skip-marks in the **same kind 10088 event** as
a separate tag type that Amethyst will never touch:
```
// Active broadcast relay (Amethyst reads this):
["r", "wss://relay.example"]
// Skip-marked relay (Amethyst ignores — not an "r" tag):
["x", "wss://private.relay.example", "auth-required", "1700000000"]
// ^ ^ ^ ^
// | url reason timestamp of failure
```
**Rules:**
- Our client reads **both** `r` and `x` tags from kind 10088.
- `r` tags = active broadcast relays (unioned into publishes).
- `x` tags = skip-marked relays (kept in the list for visibility, but excluded
from the publish relay set).
- When Amethyst updates the event, it only touches `r` tags — our `x` tags
survive untouched.
- When our client updates the event, we preserve all existing `r` tags (so we
don't clobber Amethyst's list) and only add/remove `x` tags.
- A skip-marked relay can be manually un-skipped from the UI (moves it back to
`r`), or auto-un-skipped if a retry succeeds.
**Why not a third element on the `r` tag?** Amethyst's `RelayTag.parse()`
ignores `tag[2]`, so `["r", url, "skipped"]` would technically survive parsing.
But Amethyst's `updateRelayList` does `tags.remove(RelayTag::match)` which
removes **all** `r` tags before re-adding the new list — so any third-element
annotation would be **wiped** on Amethyst's next save. A separate `x` tag is
the only safe coexistence strategy.
## Auto-seed from discovered relays
Your client already computes the perfect seed source:
[`handleGetDiscoveredRelays`](www/ndk-worker.js:6458) builds a
`relayServesPubkeys` map from all follows' kind 10002 `write`/`both` relays,
cached in the module-level `discoveredRelaysCache` variable, keyed by the
user's kind 3 contact list. The "Discovered Relays (Outbox)" section on
`relays.html` already displays this cache.
**No duplication** — the "Add all discovered relays" button reads the **same
`discoveredRelaysCache`** variable already populated by
`handleGetDiscoveredRelays`. No second query, no second cache. The two
features serve different purposes:
- **Discovered Relays** = read-only display of relays your follows use (for
awareness, cached from their kind 10002 events).
- **Broadcast Relays** = your persistent kind 10088 list of relays YOU will
publish to (user-curated, unioned into every publish).
The seed button is the one-way bridge: discovered → broadcast. The button
will:
1. Call the existing `getDiscoveredRelays` worker handler to get the cached
list of `{url, servingPubkeys, ...}` objects.
2. Extract just the URLs.
3. Union them with the existing `r`-tag broadcast relays (skip any already
present, skip any in the `x`-tag skip-mark set).
4. Save the merged list to kind 10088.
This is a **one-click, user-initiated** action — no automatic background
seeding. The user can re-run it after following new people to pick up their
relays.
## Architecture
```mermaid
flowchart LR
subgraph Page
PC[post-composer.mjs] -->|publishEvent kind:1| INIT[init-ndk.mjs]
REL[relays.html] -->|saveBroadcastRelays / seedFromDiscovered| INIT
end
INIT -->|publish / saveBroadcastRelays| W[ndk-worker.js]
subgraph Worker
W --> HP[handlePublish]
HP --> WBR{wantsBroadcastRelays?}
WBR -->|yes| FILTER[active r-tags minus x-tag skips]
WBR -->|no| DEFAULT[ndkEvent.publish default]
FILTER --> UNION[union outbox + active broadcast urls]
UNION --> RS[NDKRelaySet.fromRelayUrls]
RS --> PUB[ndkEvent.publish relaySet]
PUB --> MARK[on failure: add x-tag skip-mark to 10088]
end
subgraph Nostr
PUB --> R10002[Kind 10002 write relays]
PUB --> R10088[Kind 10088 broadcast relays - hundreds]
end
W -.->|subscribe kind 10088| R10088
R10088 -.->|live update| W
```
## Implementation plan
### 1. Worker: broadcast-relay state + hydration (`www/ndk-worker.js`)
- Add module-level state:
```js
let broadcastRelayUrls = new Set(); // active "r" tags
let skippedRelayUrls = new Map(); // url -> { reason, timestamp } ("x" tags)
let latest10088Event = null; // raw event for update-preserving saves
```
- On first init (after `fetchUserRelays`), fetch the user's kind 10088:
```js
const filter = { kinds: [10088], authors: [pubkey], limit: 1 };
const events = await ndk.fetchEvents(filter);
// pick newest event → store as latest10088Event
// Parse public tags: ["r", url] → broadcastRelayUrls
// NIP-44-decrypt content → parse private tags:
// ["r", url] → broadcastRelayUrls (Amethyst stores relays privately)
// ["x", url, reason, timestamp] → skippedRelayUrls
```
Reuse the existing `nip44Decrypt` plumbing the worker already has for the
mute list. If decryption fails or no event exists, leave both sets empty.
- Subscribe to kind 10088 for the pubkey so live updates from other tabs/
clients (including Amethyst) refresh both sets and broadcast a
`broadcastRelaysUpdated` message to all ports (mirrors the existing
`relay-types-updated` pattern at `ndk-worker.js:5177`).
- Add a helper `getActiveBroadcastUrls()` that returns
`Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u))` —
this is what `handlePublish` will use.
### 2. Worker: `wantsBroadcastRelays` gate + union in `handlePublish`
Add a helper near `handlePublish` (`ndk-worker.js:5810`):
```js
function wantsBroadcastRelays(event) {
// Public, broadcast-worthy kinds. Mirror Amethyst's Account.kt:1217.
const BROADCAST_KINDS = new Set([
1, // text note
6, // repost
7, // reaction
30023, // long-form article
30024, // draft long-form
30315, // status
31123, // public skill
31124, // private skill (still public event)
10088, // broadcast relay list itself
]);
// Explicitly EXCLUDE: 4 (DM), 10002 (relay list), 10050 (DM inbox),
// 30078 (app settings), 7375/17375 (wallet), 1059/13/14 (gift wrap/seal).
return BROADCAST_KINDS.has(Number(event.kind));
}
```
Modify `handlePublish` after signing, before `ndkEvent.publish()`:
```js
let targetRelaySet = null;
const activeBroadcastUrls = getActiveBroadcastUrls(); // r-tags minus x-tag skips
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
if (isBroadcast) {
// Union the user's normal outbox write relays with the active broadcast set.
const outboxWriteUrls = Array.from(relayTypes.entries())
.filter(([_, t]) => t === 'write' || t === 'both')
.map(([url]) => url);
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
if (NDKRelaySet?.fromRelayUrls) {
targetRelaySet = NDKRelaySet.fromRelayUrls(allUrls, ndk);
console.log(`[Worker] Broadcasting to ${allUrls.length} relays ` +
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
`${skippedRelayUrls.size} skipped)`);
}
}
// --- Live broadcast progress streaming ---
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per relay
// as each completes (see ndk-core.bundle.js:8530 and :8552). We attach
// listeners BEFORE publish() and stream progress to the page via broadcast().
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set();
const totalTarget = targetRelaySet ? targetRelaySet.relays.size : 0;
if (isBroadcast && totalTarget > 0) {
// Announce the broadcast session to all pages.
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventKind: event.kind,
eventId: ndkEvent.id || null,
phase: 'start',
total: totalTarget,
successful: 0,
failed: 0,
latestRelay: null,
});
ndkEvent.on('relay:published', (relay) => {
publishedSoFar.add(relay.url);
trackRelayWrite(relay.url);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: relay.url,
});
});
ndkEvent.on('relay:publish:failed', (relay, err) => {
failedSoFar.add(relay.url);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: relay.url,
latestError: err?.message || String(err),
});
});
}
const relaySet = await ndkEvent.publish(targetRelaySet);
// Final broadcast progress message with complete results.
if (isBroadcast && totalTarget > 0) {
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'done',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: null,
});
// Clean up listeners.
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
}
```
`NDKRelaySet.fromRelayUrls` uses `pool.useTemporaryRelay` for URLs not already
in the pool, so hundreds of broadcast relays connect transiently for the
publish and do not interfere with the user's read subscriptions.
**Failure marking** — after `ndkEvent.publish()` returns, the existing code at
[`ndk-worker.js:5856`](www/ndk-worker.js:5856) already computes
`relayResults.failed` (connected relays that didn't accept the event). Extend
it to persist skip-marks for broadcast relays that failed:
```js
// After the existing relayResults.failed computation:
if (isBroadcast) {
const newSkips = relayResults.failed
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
skippedRelayUrls.set(url, {
reason: 'publish-failed',
timestamp: Math.floor(Date.now() / 1000),
});
}
// Persist the updated skip-marks to kind 10088 (debounced — see step 3).
scheduleSkipMarkSave();
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays`);
}
}
```
The skip-mark save is **debounced** (e.g. 5s) so a single post that fails on
20 relays produces one kind 10088 republish, not 20. The save preserves all
existing `r` tags and only updates the `x` tags (see step 3).
### 3. Worker: `getBroadcastRelays` + `saveBroadcastRelays` + `seedBroadcastRelays` + skip-mark handlers
Add new message cases in the worker's `switch` (near `ndk-worker.js:6980`):
- `getBroadcastRelays` → respond with:
```js
{
active: Array.from(broadcastRelayUrls),
skipped: Array.from(skippedRelayUrls.entries()).map(([url, info]) => ({ url, ...info })),
}
```
- `saveBroadcastRelays` → build/update a kind 10088 event. **Critical: preserve
existing `r` tags and all `x` tags** so we coexist with Amethyst:
```js
// Start from latest10088Event if we have one (preserve Amethyst's r-tags
// that the user might not have in our local set yet). Otherwise start fresh.
const existingRTags = latest10088Event
? latest10088Event.tags.filter(t => t[0] === 'r')
: [];
// Merge: union the user's new urls with existing r-tags.
const mergedRUrls = new Set([
...existingRTags.map(t => t[1]),
...urls, // the new active list from the UI
]);
// Preserve existing x-tags (skip-marks) — never touch them on a user save.
const existingXTags = latest10088Event
? latest10088Event.tags.filter(t => t[0] === 'x')
: [];
// NIP-44-encrypt the private tags (r + x) as the content.
const privateTags = [
...Array.from(mergedRUrls).map(u => ['r', u]),
...existingXTags,
];
const encrypted = await nip44Encrypt(currentPubkey, JSON.stringify(privateTags));
const event = {
kind: 10088,
content: encrypted,
tags: [], // public tags empty — keep private like Amethyst's create()
created_at: Math.floor(Date.now() / 1000),
};
// sign via signEventWithMessageSigner, then publish (default outbox).
// Update broadcastRelayUrls locally + broadcast broadcastRelaysUpdated.
```
- `seedBroadcastRelays` → one-click "Add all discovered relays":
```js
// Reuse the discovered-relays cache (handleGetDiscoveredRelays logic).
// Get the cached list of {url, ...} objects.
const discovered = discoveredRelaysCache || [];
const discoveredUrls = discovered.map(r => r.url).filter(Boolean);
// Union with existing active r-tags, excluding skip-marked relays.
const newUrls = discoveredUrls.filter(u =>
!broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
);
if (newUrls.length === 0) {
port.postMessage({ type: 'response', requestId, data: { added: 0 } });
return;
}
// Merge into broadcastRelayUrls and save.
for (const u of newUrls) broadcastRelayUrls.add(u);
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
port.postMessage({ type: 'response', requestId, data: { added: newUrls.length } });
```
- `unskipBroadcastRelay` → move a relay from the `x`-tag skip set back to
active (user clicked "Retry" / "Unskip" in the UI):
```js
skippedRelayUrls.delete(url);
// No need to add to broadcastRelayUrls — it's already there (we keep
// skipped relays in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls).
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
```
- `scheduleSkipMarkSave()` (internal, debounced) — called by `handlePublish`
after failure marking. Same save logic as `saveBroadcastRelays` but only
updates the `x` tags, preserving the current `r` tags. Debounce with a 5s
timer so multiple failures in one publish produce one republish.
Reuse the existing `signEventWithMessageSigner` and the existing
`nip44Encrypt`/`nip44Decrypt` helpers the worker already wires for the mute
list and NIP-17 messaging.
### 4. `init-ndk.mjs` exports + progress forwarding
Add alongside [`publishEvent`](www/js/init-ndk.mjs:629):
```js
export async function getBroadcastRelays() { /* sendWorkerRequest → {active, skipped} */ }
export async function saveBroadcastRelays(urls) { /* sendWorkerRequest */ }
export async function seedBroadcastRelays() { /* sendWorkerRequest → {added} */ }
export async function unskipBroadcastRelay(url) { /* sendWorkerRequest */ }
```
Forward two new worker message types to window events:
1. `broadcastRelaysUpdated` →
`window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated', {detail}))`
(same pattern as `ndkRelayActivity` at `ndk-worker.js:2673`).
2. `broadcastProgress` →
`window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {detail}))`
so pages can show live publish progress. The detail payload:
```js
{
sessionId, phase, // 'start' | 'progress' | 'done'
eventKind, eventId,
total, // total relays being published to
successful, // count so far
failed, // count so far
latestRelay, // URL of the most recent relay to respond
latestError, // error message if latestRelay failed
}
```
### 5. New module `www/js/broadcast-relays.mjs`
Thin wrapper mirroring [`mute-list.mjs`](www/js/mute-list.mjs:41):
```js
import {
getBroadcastRelays, saveBroadcastRelays,
seedBroadcastRelays, unskipBroadcastRelay, getPubkey
} from './init-ndk.mjs';
let active = new Set();
let skipped = new Map(); // url -> { reason, timestamp }
const listeners = new Set();
export async function loadBroadcastRelays() {
const { active: a, skipped: s } = await getBroadcastRelays();
active = new Set(a);
skipped = new Map(s.map(item => [item.url, item]));
notify();
}
export function getActiveBroadcastRelays() { return Array.from(active); }
export function getSkippedBroadcastRelays() { return Array.from(skipped.values()); }
export function isBroadcastRelay(url) { return active.has(url); }
export async function addBroadcastRelay(url) {
active.add(normalize(url));
await saveBroadcastRelays(Array.from(active));
notify();
}
export async function removeBroadcastRelay(url) {
active.delete(normalize(url));
await saveBroadcastRelays(Array.from(active));
notify();
}
export async function seedFromDiscovered() {
const { added } = await seedBroadcastRelays();
await loadBroadcastRelays(); // refresh from worker
return added;
}
export async function unskipRelay(url) {
await unskipBroadcastRelay(url);
await loadBroadcastRelays();
}
export function onBroadcastRelaysChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); }
function notify() { listeners.forEach(cb => cb({ active: Array.from(active), skipped: Array.from(skipped.values()) })); }
```
### 6. `relays.html` sidenav — broadcast relays management UI
The broadcast relays management UI lives in the **sidenav** below the "Show
connection history" toggle, not in the main body. The sidenav body is rendered
in `openNav()` at [`relays.html:428`](www/relays.html:428).
Extend the `divSideNavBody.innerHTML` template to add a broadcast relays
section below the connection history toggle:
```html
<div id="divRelaySettings" style="..."> <!-- existing: Show connection history -->
...
</div>
<!-- NEW: Broadcast relays section -->
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
<span style="flex:1;">Broadcast Relays
(<span id="spanBroadcastActive">0</span> active,
<span id="spanBroadcastSkipped">0</span> skipped)
</span>
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
</div>
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
style="flex:1;font-size:80%;padding:2px 4px;" />
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
</div>
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
Add all discovered relays
</button>
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
</div>
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
Save to Nostr (kind 10088)
</button>
</div>
</div>
```
Wire it with `broadcast-relays.mjs`:
- On `openNav()`: `loadBroadcastRelays()` → render active + skipped lists +
update count badges.
- Toggle header click → show/hide `divBroadcastRelaysContent`.
- Add button → `addBroadcastRelay(url)` → re-render.
- "Add all discovered relays" button → `seedFromDiscovered()` → show
"Added N relays" toast → re-render.
- Each active row has a remove button → `removeBroadcastRelay(url)`.
- Each skipped row shows the failure reason + timestamp, and an "Unskip"
button → `unskipRelay(url)` → moves it back to active.
- `onBroadcastRelaysChanged` → re-render + update count badges.
- Listen for `ndkBroadcastRelaysUpdated` window event for cross-tab sync.
Styling: reuse the existing sidenav font-size (80%) and `var(--primary-color)`
tokens so it matches the "Show connection history" toggle above it. Skipped
relays render with reduced opacity to distinguish them from active relays.
### 7. Live cross-tab sync
The worker's kind 10088 subscription (step 1) updates `broadcastRelayUrls` and
broadcasts `broadcastRelaysUpdated` to all ports. `init-ndk.mjs` forwards this
to `window.dispatchEvent`. Any open `relays.html` re-renders; any pending
publish in any tab automatically uses the freshest relay set.
### 8. `post.html` footer — live broadcast progress display
**Remove the comments toggle button** from the footer center section
([`post.html:160-162`](www/post.html:160)):
```html
<!-- REMOVE: -->
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
```
Also remove the comments toggle event listener at
[`post.html:813-817`](www/post.html:813) and the
`getCommentsVisible`/`setCommentsVisible` calls. Comments are no longer active
on this page.
**Replace with a broadcast progress display:**
```html
<div id="divFooterCenter" class="divFooterBox">
<span id="spanBroadcastStatus"></span>
</div>
```
Wire it to the `ndkBroadcastProgress` window event:
```js
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
// Show the running total + the latest relay that responded.
// Truncate the relay URL for the footer's limited width.
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
spanBroadcastStatus.textContent =
`📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
} else if (d.phase === 'done') {
spanBroadcastStatus.textContent =
`✅ ${d.successful}/${d.total} relays` +
(d.failed > 0 ? ` (${d.failed} failed)` : '');
// Clear after 10 seconds so the footer doesn't show stale info forever.
setTimeout(() => {
if (spanBroadcastStatus.textContent.startsWith('✅')) {
spanBroadcastStatus.textContent = '';
}
}, 10000);
}
});
```
The display shows:
- **`start`**: "Broadcasting to N relays…"
- **`progress`**: "📡 42/150 relays · relay.example.com" — the count
continuously increments as each relay responds, with the latest relay's
hostname trailing.
- **`done`**: "✅ 148/150 relays (2 failed)" — final result, auto-clears after
10s.
This is a **reusable function** — the `ndkBroadcastProgress` event fires for
every broadcast publish from any page. Other pages (feed.html, post-feed.html)
can add the same listener if they want to display broadcast progress in their
own footers later.
## Files touched
| File | Change |
|------|--------|
| [`www/ndk-worker.js`](www/ndk-worker.js) | +state (active + skipped), +hydration from kind 10088, +subscription, +`wantsBroadcastRelays`, +union in `handlePublish` with skip filtering, +live progress streaming via `relay:published`/`relay:publish:failed` events, +failure marking, +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` handlers, +debounced skip-mark save |
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` exports, +`ndkBroadcastRelaysUpdated` + `ndkBroadcastProgress` window event forwarding |
| [`www/js/broadcast-relays.mjs`](www/js/broadcast-relays.mjs) | **NEW** — load/save/add/remove/seed/unskip/subscribe wrapper |
| [`www/relays.html`](www/relays.html) | +Broadcast Relays management UI in sidenav below "Show connection history" toggle (active list, skipped list, add, seed-from-discovered, unskip, save) + wiring |
| [`www/post.html`](www/post.html) | Remove comments toggle button + listener, add broadcast progress display in footer center section wired to `ndkBroadcastProgress` |
No changes to `post-composer.mjs` or any page that calls `publishEvent` — the
union is transparent and always-on at the worker layer.
## Kinds reference
- **10088** — Broadcast Relay List (NIP-51 private list, NIP-44 encrypted
content, replaceable `d=""`). Defined by Amethyst's quartz.
- `["r", url]` tags = active broadcast relays (read by both our client and
Amethyst).
- `["x", url, reason, timestamp]` tags = skip-marked relays (read only by
our client; Amethyst's `RelayTag` ignores non-`r` tags).
- **10002** — NIP-65 Relay List (unchanged; still the user's standard
outbox/inbox).
- **1, 6, 7, 30023, 30315, 31123, 31124** — public event kinds that get the
broadcast union.
## Testing
1. Open `relays.html`, add 3-5 broadcast relays manually, click Save.
2. Verify the kind 10088 event appears in the event-management page (kind
10088 row) and that its content is NIP-44-encrypted.
3. Click "Add all discovered relays" → confirm N relays added from follows'
kind 10002 lists.
4. Post a kind 1 from `post.html`.
5. **Footer progress display**: watch the footer center section — it should
show "Broadcasting to N relays…", then continuously update
"📡 42/150 relays · relay.example.com" as each relay responds, then settle
on "✅ 148/150 relays (2 failed)" and auto-clear after 10s.
6. Check the worker console log: `[Worker] Broadcasting to N relays (X
broadcast + Y outbox, Z skipped)`.
7. Confirm the post lands on the broadcast relays (query one via
`publishRawEventToRelay`-style fetch or an external tool like `nak`).
8. Confirm a kind 4 DM or kind 30078 settings save does **not** go to
broadcast relays (check the log lacks the "Broadcasting to" line, and the
footer shows no broadcast progress).
9. Confirm the old "Comments" toggle button is gone from the `post.html`
footer and no longer appears.
10. **Skip-mark test**: add a relay that will reject the publish (e.g. a
private/auth-required relay). Post a kind 1. Confirm:
- The publish log shows the relay in `failed`.
- The worker logs `Skip-marked N failed broadcast relays`.
- The kind 10088 event is republished with an `["x", url, ...]` tag.
- In the `relays.html` sidenav, the relay appears in the "Skipped" section
with the failure reason.
- A subsequent kind 1 publish does **not** attempt that relay (count in
log drops by 1).
11. **Unskip test**: click "Unskip" on a skipped relay → confirm it moves back
to active and is included in the next publish.
12. **Amethyst coexistence test**: if Amethyst is available, edit the
broadcast relay list in Amethyst → confirm our client's `relays.html`
sidenav updates live (kind 10088 subscription). Then edit in our client →
confirm Amethyst still shows its `r`-tag relays (our `x` tags don't
interfere).
13. Open a second tab, edit the broadcast list in the sidenav, confirm the
first tab's `relays.html` sidenav updates live.

View File

@@ -0,0 +1,507 @@
# Long-Running Memory Bloat & Unresponsiveness Fix Plan
## Problem Statement
When the client runs for extended periods, pages become bloated and unresponsive. The root causes fall into four categories:
1. **Unbounded in-memory growth** — arrays/Maps/Sets that accumulate events without eviction
2. **Listener/event accumulation** — DOM listeners and worker subscriptions that are never cleaned up
3. **Over-aggressive polling** — 1-second intervals on every page, each triggering multiple worker round-trips
4. **Full re-renders**`innerHTML = ''` + rebuild on every incoming event, discarding DOM and re-attaching all listeners
The SharedWorker broadcasts every subscription event to every connected tab, and pages process all of them regardless of which subscription they originated from. Combined with full-DOM-rebuild rendering, this causes O(n) work on every event and linear memory growth over the session lifetime.
## Scope
**Pages affected:** `index.html`, `feed.html`, `post.html`, `notifications.html`, `relays.html`
**Worker affected:** `ndk-worker.js`
**Shared module affected:** `js/init-ndk.mjs`
## Architecture Overview
```mermaid
flowchart LR
subgraph Worker[ndk-worker.js SharedWorker]
sub[activeSubscriptions Map]
stats[relayActivityStats Map]
ports[connectedPorts array]
wallet[lightwalletTokenEvents Map]
broadcast[broadcast to all ports]
end
subgraph Tabs[Open Tabs - each gets ALL broadcasts]
index[index.html]
feed[feed.html]
post[post.html]
notif[notifications.html]
relays[relays.html]
end
sub -->|event/eose per sub| broadcast
stats -->|relayActivity per read/write| broadcast
broadcast -->|unfiltered| index
broadcast -->|unfiltered| feed
broadcast -->|unfiltered| post
broadcast -->|unfiltered| notif
broadcast -->|unfiltered| relays
feed -->|posts array grows forever| MemLeak[Memory Leak]
notif -->|notificationsById grows forever| MemLeak
notif -->|document.click leaked per row| ListenerLeak[Listener Leak]
feed -->|innerHTML rebuild per event| CPUSpike[CPU Spike]
relays -->|table rebuild every 5s| CPUSpike
```
## Implementation Phases
### Phase 1: P0 — Critical Rendering & Listener Leaks
**Goal:** Eliminate the two most severe causes of user-visible jank and memory growth.
#### 1.1 Append-only rendering for feed and post pages
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
**Current behavior:** [`renderFeed()`](www/feed.html:460) does `divFeed.innerHTML = ''` and rebuilds all cards on every incoming event.
**Changes:**
- Split `renderFeed()` into two functions:
- `renderFeed()` — full rebuild, only called on initial load, "See More", sort changes, or mute removal
- `prependPostCard(post)` — creates a single card, inserts it at the top of `divFeed` if newer than the first visible post, and wires interactions for just that one card
- In the `ndkEvent` handler, call `prependPostCard(evt)` instead of `renderFeed()` when a new kind-1 event arrives and the feed is already rendered
- Cap the visible DOM: if `divFeed.children.length > displayCount + SEE_MORE_INCREMENT`, remove the last child
- Keep `posts[]` sorted by inserting new events in the correct position (binary insert or simple unshift + periodic sort)
**Acceptance criteria:**
- New posts appear at the top without rebuilding existing cards
- Existing images/videos are not reloaded when a new post arrives
- "See More" still does a full re-render to show additional posts
#### 1.2 Append-only rendering for notifications page
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** [`renderNotifications()`](www/notifications.html:886) does `divNotifications.innerHTML = ''` and rebuilds all rows on every new notification.
**Changes:**
- Split into `renderNotifications()` (full rebuild for filter changes / "View more") and `prependNotificationRow(item)` (single new row)
- In `addNotificationFromEvent`, call `prependNotificationRow` when the notifications list is already rendered and the new item passes the filter
- Cap visible DOM to `visibleNotificationsCount + NOTIFICATIONS_PAGE_SIZE`
**Acceptance criteria:**
- New notifications appear without rebuilding existing rows
- Filter toggles and "View more" still do full re-renders
#### 1.3 Fix notification `document.click` listener leak
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** [`renderNotifications()`](www/notifications.html:1046) registers a `document.addEventListener('click', ...)` for **every** notification row, and these are never removed.
**Changes:**
- Remove the per-row `document.addEventListener('click', ...)` entirely
- Register a **single** document-level click listener in `main()` that closes any open menu panel by checking `document.querySelector('.notif-menu-btn[aria-expanded="true"]')` or similar
- Alternatively, use a module-level `openMenuPanel` variable; the single listener checks if the click target is outside `openMenuPanel` and closes it
**Acceptance criteria:**
- Only one document click listener exists regardless of notification count
- Menu panels still close when clicking outside
#### 1.4 Cap `posts[]` and `postIds` with rolling eviction
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
**Current behavior:** `posts` array and `postIds` Set grow without bound.
**Changes:**
- Add `const MAX_STORED_POSTS = 500` constant
- In `upsertFeedPost` / `upsertPost`, after pushing a new event, if `posts.length > MAX_STORED_POSTS`, sort by `created_at` descending, slice to `MAX_STORED_POSTS`, and rebuild `postIds` from the remaining array
- Also remove evicted post IDs from `renderedPostIds`
- In `post.html` event mode, skip this (single event display)
**Acceptance criteria:**
- `posts.length` never exceeds `MAX_STORED_POSTS`
- Evicted posts are removed from `postIds` and `renderedPostIds`
- "See More" still works within the capped array
#### 1.5 Cap `notificationsById` and `unreadNotificationsById` with periodic pruning
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** Both Maps grow without bound; [`pruneNotificationsByCurrentSettings`](www/notifications.html:570) only runs on filter toggles.
**Changes:**
- Add `const MAX_STORED_NOTIFICATIONS = 500`
- Add a `pruneNotificationMaps()` function that:
- Sorts `notificationsById` entries by `created_at` descending
- Keeps only the top `MAX_STORED_NOTIFICATIONS`
- Deletes evicted entries from both `notificationsById` and `unreadNotificationsById`
- Also prunes `postImageCache` to an LRU of 200 entries
- Call `pruneNotificationMaps()` at the end of `addNotificationFromEvent` if `notificationsById.size > MAX_STORED_NOTIFICATIONS + 100`
- Also call it after `hydrateNotificationsFromCache` and `hydrateNotificationsFromRelays`
**Acceptance criteria:**
- `notificationsById.size` never exceeds `MAX_STORED_NOTIFICATIONS + 100`
- `postImageCache.size` never exceeds 200
- Filter counts remain accurate after pruning
---
### Phase 2: P1 — Subscription Hygiene & Polling Reduction
**Goal:** Stop the cross-tab event storm and reduce worker message queue saturation.
#### 2.1 Filter `ndkEvent` by `subId` on the page side
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html), [`www/notifications.html`](www/notifications.html), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
**Current behavior:** The worker includes `subId` in event broadcasts ([`ndk-worker.js:1094`](www/ndk-worker.js:1094)), but pages' `ndkEvent` handlers process all events regardless of source subscription.
**Changes:**
- In `init-ndk.mjs`, the `ndkEvent` CustomEvent already includes the full `message.data` — verify `subId` is in `event.detail`
- Each page tracks its own active subscription IDs in a `Set`:
- `feed.html`: track the kind-3 sub and the live feed sub
- `post.html`: track the kind-1 author/event sub
- `notifications.html`: track the notifications sub
- In the `ndkEvent` handler, check `if (!mySubscriptionIds.has(event.detail.subId)) return;` before processing
**Acceptance criteria:**
- Pages only process events from their own subscriptions
- Events from other tabs' subscriptions are ignored (no CPU work, no DOM updates)
#### 2.2 Close old live subscriptions on follow-list change
**Files:** [`www/feed.html`](www/feed.html)
**Current behavior:** [`ensureLiveFeedSubscription`](www/feed.html:548) creates a new subscription when follows change but never closes the old one.
**Changes:**
- Add a module-level `let liveFeedSub = null;` variable
- In `ensureLiveFeedSubscription`, before creating the new subscription:
```js
if (liveFeedSub?.close) {
liveFeedSub.close();
}
```
- Assign the return of `subscribe()` to `liveFeedSub`
- Also track the kind-3 subscription similarly and close it on logout
**Acceptance criteria:**
- Only one live feed subscription exists at a time in the worker
- `activeSubscriptions` Map in the worker doesn't accumulate stale feed subscriptions
#### 2.3 Increase footer polling interval to 5 seconds (or event-driven)
**Files:** [`www/index.html`](www/index.html), [`www/feed.html`](www/feed.html), [`www/notifications.html`](www/notifications.html), [`www/relays.html`](www/relays.html)
**Current behavior:** `setInterval(UpdateFooter, 1000)` on every page.
**Changes:**
- Change all 1-second footer intervals to 5 seconds (matching `post.html` which already uses 5s)
- `index.html` line 850: `setInterval(UpdateFooter, 5000)`
- `feed.html` line 692: `setInterval(updateFooter, 5000)`
- `notifications.html` line 1390: `setInterval(UpdateFooter, 5000)`
- `relays.html` line 1794: `setInterval(UpdateFooter, 5000)`
- **Future enhancement (not in this phase):** Make footer fully event-driven by reacting to `ndkRelays` and `ndkRelayActivity` events instead of polling
**Acceptance criteria:**
- No page polls the worker for footer data more than once per 5 seconds
- Footer still shows current relay status (5s staleness is acceptable)
#### 2.4 Diff-update relay table instead of full rebuild
**Files:** [`www/relays.html`](www/relays.html)
**Current behavior:** [`createRelayTable`](www/relays.html:795) does `divRelays.innerHTML = html` every 5 seconds, destroying all rows, inputs, and listeners.
**Changes:**
- Split `createRelayTable` into:
- `buildRelayTable(relays)` — initial build (full innerHTML)
- `updateRelayTableRows(relays)` — in-place update of existing rows:
- Update status icon, read/write counts, connection time text
- Update checkbox SVGs if relay type changed
- Add/remove rows if relay count changed
- Preserve the add-relay input value and focus
- `refreshRelayData` calls `updateRelayTableRows` if the table already exists and the relay count matches; otherwise calls `buildRelayTable`
**Acceptance criteria:**
- Relay table updates without losing input focus or re-attaching all listeners
- Add-relay input value is preserved across refreshes (already partially implemented)
#### 2.5 Throttle `relayActivity` broadcasts in the worker
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** [`trackRelayRead`](www/ndk-worker.js:828) and [`trackRelayWrite`](www/ndk-worker.js:850) broadcast `relayActivity` on every single event read/write.
**Changes:**
- Add a `relayActivityBroadcastTimer` and a `pendingRelayActivity` Set of relay URLs that have had activity since the last broadcast
- In `trackRelayRead` / `trackRelayWrite`, add the relay URL to `pendingRelayActivity` and schedule a 500ms batched broadcast if not already scheduled
- The batched broadcast sends one `relayActivity` message per pending relay with current stats, then clears the set
- This reduces broadcast frequency from per-event to per-500ms-batch
**Acceptance criteria:**
- `relayActivity` broadcasts are batched to at most once per 500ms
- Stats counts remain accurate (they're cumulative)
- Footer/sidenav relay indicators still update within 1s of activity
---
### Phase 3: P2 — Worker Cleanup & Cache Eviction
**Goal:** Prevent unbounded growth in worker-side state and clean up dead connections.
#### 3.1 Prune `connectedPorts` on tab close
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** [`connectedPorts`](www/ndk-worker.js:260) accumulates dead ports; [`broadcast()`](www/ndk-worker.js:2479) iterates all of them.
**Changes:**
- In the `onconnect` handler ([`ndk-worker.js:7600`](www/ndk-worker.js:7600)), add:
```js
port.onclose = () => {
connectedPorts = connectedPorts.filter(p => p !== port);
if (activeSigningPort === port) activeSigningPort = null;
};
```
**Acceptance criteria:**
- `connectedPorts` only contains live ports
- `broadcast()` doesn't iterate dead ports
#### 3.2 Evict `inlineComposerInstances` on re-render
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
**Current behavior:** `inlineComposerInstances` Map retains entries with detached `hostEl` after `renderFeed()` clears the container.
**Changes:**
- At the start of `renderFeed()` (the full rebuild path), after `divFeed.innerHTML = ''`:
```js
inlineComposerInstances.clear();
```
- Since the DOM is rebuilt, all composer host elements are gone; the Map entries are pure garbage
**Acceptance criteria:**
- `inlineComposerInstances` is empty after a full re-render
- Composers can still be re-created on demand via `getOrCreateInlineComposer`
#### 3.3 Prune temporary relay Maps after broadcast publish
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** `relayActivityStats`, `relayConnectAttemptStartedAt`, `relayLastDisconnectMeta` accumulate entries for every temporary broadcast relay.
**Changes:**
- After the broadcast publish `phase: 'done'` block ([`ndk-worker.js:6410`](www/ndk-worker.js:6410)), add a cleanup step:
```js
const userRelayUrls = new Set(Array.from(relayTypes.keys()).map(normalizeRelayUrl));
for (const url of relayActivityStats.keys()) {
if (!userRelayUrls.has(url)) relayActivityStats.delete(url);
}
relayConnectAttemptStartedAt.clear(); // only used during active connect attempts
// Keep relayLastDisconnectMeta for reconnect detection
```
- Only prune `relayActivityStats` — the disconnect meta is needed for reconnect subscription rebuilding
**Acceptance criteria:**
- `relayActivityStats` only contains entries for the user's own relays after a broadcast completes
- Reconnect detection still works (disconnect meta preserved)
#### 3.4 Periodic reconciliation of `lightwalletTokenEvents`
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** `lightwalletTokenEvents` and `lightwalletDeletedIds` grow monotonically; deleted entries are only removed during `publishDirectProofs`.
**Changes:**
- Add a 5-minute interval timer `lightwalletReconcileTimer` that:
- Iterates `lightwalletTokenEvents` and removes any entry whose id is in `lightwalletDeletedIds`
- Optionally trims `lightwalletDeletedIds` to the most recent 500 entries (keep recent ones to prevent re-processing)
- Clear this timer in `handleDisconnect`
**Acceptance criteria:**
- `lightwalletTokenEvents` doesn't retain deleted token events
- `lightwalletDeletedIds` is bounded to ~500 entries
#### 3.5 Clear health-check and prune intervals on disconnect
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
**Current behavior:** `startRelayHealthCheck` ([`ndk-worker.js:1262`](www/ndk-worker.js:1262)) and `scheduleRelayEventPruning` ([`ndk-worker.js:789`](www/ndk-worker.js:789)) create intervals that are never cleared.
**Changes:**
- Store the health-check interval ID in a module-level variable `relayHealthCheckTimer`
- Store the prune interval ID in `relayEventsPruneTimer` (already exists)
- In `handleDisconnect()` ([`ndk-worker.js:7350`](www/ndk-worker.js:7350)), add:
```js
if (relayHealthCheckTimer) { clearInterval(relayHealthCheckTimer); relayHealthCheckTimer = null; }
if (relayEventsPruneTimer) { clearInterval(relayEventsPruneTimer); relayEventsPruneTimer = null; }
```
**Acceptance criteria:**
- No duplicate intervals after re-init
- Intervals are cleared on disconnect
---
### Phase 4: P3 — Minor Leaks & Polish
**Goal:** Clean up remaining minor leaks and harden against edge cases.
#### 4.1 Clear `refreshTimes` interval on notifications logout
**Files:** [`www/notifications.html`](www/notifications.html)
**Current behavior:** [`setInterval(refreshTimes, 30000)`](www/notifications.html:1392) is never assigned to a variable and can't be cleared.
**Changes:**
- Assign to `let refreshTimesIntervalId = setInterval(refreshTimes, 30000)`
- Clear it in `Logout()`
#### 4.2 Remove duplicate `window.addEventListener('message', ...)` in relays.html
**Files:** [`www/relays.html`](www/relays.html)
**Current behavior:** [`relays.html:1777`](www/relays.html:1777) has a fallback `message` listener that duplicates the `ndkRelayActivity` handler.
**Changes:**
- Remove the `window.addEventListener('message', ...)` block at lines 17771783
- The `ndkRelayActivity` handler at line 1762 already covers this
#### 4.3 Add `beforeunload` cleanup for subscriptions on all pages
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html), [`www/notifications.html`](www/notifications.html)
**Current behavior:** Pages don't close their subscriptions on `beforeunload`, leaving stale entries in the worker's `activeSubscriptions` Map until the worker detects the port close.
**Changes:**
- Add `window.addEventListener('beforeunload', () => { disconnect(); })` (or close individual subscriptions) on each page
- The `disconnect()` function in `init-ndk.mjs` already sends a `disconnect` message and closes the port
**Acceptance criteria:**
- Worker's `activeSubscriptions` is cleaned up when a tab closes
---
## Testing Strategy
### Manual Testing
1. Open `index.html`, `feed.html`, `post.html`, `notifications.html`, `relays.html` in separate tabs
2. Leave them running for 30+ minutes with active subscriptions
3. Check browser DevTools Memory tab:
- Take heap snapshots at 5min, 15min, 30min
- Verify heap growth is sublinear (not growing with event count)
4. Check Performance tab:
- Record a 30s profile while events are flowing
- Verify no full `renderFeed` / `renderNotifications` on every event
5. Check that relay footer updates within 5s of connection changes
6. Check that notification menu panels still open/close correctly
### Automated Checks
- After each phase, run the existing test suite (`tests/broadcast-relay-test.sh` etc.)
- Add a memory regression test if feasible (puppeteer heap snapshot comparison)
## Risk Assessment
| Phase | Risk | Mitigation |
|-------|------|------------|
| Phase 1 (append-only) | Sort order bugs if new event is older than visible posts | Only prepend if newer than first visible; fall back to full render otherwise |
| Phase 1 (listener fix) | Menu panel might not close in all cases | Test thoroughly; use single delegated listener |
| Phase 2 (subId filter) | Might miss events if subId tracking is wrong | Log filtered vs processed events during testing |
| Phase 2 (polling change) | Footer might feel less responsive | 5s is still fast; relay status rarely changes in seconds |
| Phase 3 (worker cleanup) | Might prune stats too aggressively | Only prune temp relay stats, not user's own relays |
## Files Modified
| File | Phases |
|------|--------|
| `www/feed.html` | 1, 2, 3, 4 |
| `www/post.html` | 1, 2, 3, 4 |
| `www/notifications.html` | 1, 2, 3, 4 |
| `www/relays.html` | 2, 4 |
| `www/index.html` | 2 |
| `www/ndk-worker.js` | 2, 3 |
| `www/js/init-ndk.mjs` | 2 |
## Implementation Checklist
### Phase 1: P0 — Critical Rendering & Listener Leaks
- [x] **1.1** Append-only rendering for feed and post pages
- [x] Split `renderFeed()` into full rebuild + `prependPostCard()`
- [x] New posts prepend without rebuilding existing cards
- [x] Cap visible DOM to `displayCount` (trims last child on overflow)
- [x] "See More" still does full re-render
- [x] Verified: `data-post-id` attribute set by `renderPostItem` for trim logic
- [x] Reviewed: no errors found, fallback paths correct
- [x] **1.2** Append-only rendering for notifications page
- [x] Split `renderNotifications()` into full rebuild + `prependNotificationRow()`
- [x] Extracted `buildNotificationRow(item)` helper shared by both functions
- [x] New notifications prepend without rebuilding existing rows
- [x] Filter toggles and "View more" still do full re-renders
- [x] **1.3** Fix notification `document.click` listener leak
- [x] Remove per-row `document.addEventListener('click', ...)`
- [x] Register single delegated document-level click listener in `main()`
- [x] Added `notif-menu-panel` class for panel discovery
- [x] Menu panels still close when clicking outside
- [x] **1.4** Cap `posts[]` and `postIds` with rolling eviction
- [x] Add `MAX_STORED_POSTS = 500` constant
- [x] Evict oldest posts when array exceeds cap (+50 batch threshold)
- [x] Rebuild `postIds` and `renderedPostIds` after eviction
- [x] Skip pruning in post.html event mode
- [x] **1.5** Cap `notificationsById` / `unreadNotificationsById` with periodic pruning
- [x] Add `MAX_STORED_NOTIFICATIONS = 500`
- [x] Add `pruneNotificationMaps()` function
- [x] Call prune on overflow, after cache/relay hydration
- [x] LRU-cap `postImageCache` to 200 entries
### Phase 2: P1 — Subscription Hygiene & Polling Reduction
- [ ] **2.1** Filter `ndkEvent` by `subId` on the page side
- [ ] Verify `subId` is in `event.detail` from init-ndk.mjs
- [ ] Track active subscription IDs per page
- [ ] Ignore events from foreign subscriptions
- [ ] **2.2** Close old live subscriptions on follow-list change
- [ ] Store live feed subscription handle
- [ ] Close previous subscription before creating new one
- [ ] **2.3** Increase footer polling to 5 seconds
- [ ] index.html: 1s → 5s
- [ ] feed.html: 1s → 5s
- [ ] notifications.html: 1s → 5s
- [ ] relays.html: 1s → 5s
- [ ] **2.4** Diff-update relay table instead of full rebuild
- [ ] Split `createRelayTable` into build + update
- [ ] In-place update of status icons, counts, checkboxes
- [ ] Preserve add-relay input focus across refreshes
- [ ] **2.5** Throttle `relayActivity` broadcasts in the worker
- [ ] Add batched broadcast with 500ms timer
- [ ] Coalesce per-event broadcasts into per-batch
- [ ] Stats counts remain accurate
### Phase 3: P2 — Worker Cleanup & Cache Eviction
- [ ] **3.1** Prune `connectedPorts` on tab close
- [ ] Add `port.onclose` handler in `onconnect`
- [ ] Clear `activeSigningPort` if it was the closed port
- [ ] **3.2** Evict `inlineComposerInstances` on re-render
- [ ] Clear Map after `innerHTML = ''` in full rebuild path
- [ ] **3.3** Prune temporary relay Maps after broadcast publish
- [ ] Remove non-user relay entries from `relayActivityStats`
- [ ] Clear `relayConnectAttemptStartedAt`
- [ ] Preserve `relayLastDisconnectMeta` for reconnect detection
- [ ] **3.4** Periodic reconciliation of `lightwalletTokenEvents`
- [ ] Add 5-minute interval timer
- [ ] Remove deleted entries from `lightwalletTokenEvents`
- [ ] Trim `lightwalletDeletedIds` to 500
- [ ] Clear timer in `handleDisconnect`
- [ ] **3.5** Clear health-check and prune intervals on disconnect
- [ ] Store health-check interval ID
- [ ] Clear both intervals in `handleDisconnect`
### Phase 4: P3 — Minor Leaks & Polish
- [ ] **4.1** Clear `refreshTimes` interval on notifications logout
- [ ] **4.2** Remove duplicate `message` listener in relays.html
- [ ] **4.3** Add `beforeunload` subscription cleanup on all pages

File diff suppressed because it is too large Load Diff

95
tests/broadcast-relay-test.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/bin/bash
# tests/broadcast-relay-test.sh
#
# Tests publishing a kind 1 note to each relay in tests/relays.json
# using nak, one relay at a time. Reports success/failure for each.
#
# Usage:
# ./tests/broadcast-relay-test.sh
#
# Requirements:
# - nak (https://github.com/fiatjaf/nak) installed and in PATH
# - tests/test-key.txt containing a hex private key
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RELAYS_FILE="$SCRIPT_DIR/relays.json"
RESULTS_FILE="$SCRIPT_DIR/broadcast-relay-results.txt"
KEY_FILE="$SCRIPT_DIR/test-key.txt"
# Check for nak
if ! command -v nak &>/dev/null; then
echo "ERROR: nak is not installed."
exit 1
fi
# Check for key file
if [ ! -f "$KEY_FILE" ]; then
echo "ERROR: $KEY_FILE not found. Generate one with: nak key generate > $KEY_FILE"
exit 1
fi
SEC=$(cat "$KEY_FILE" | tr -d '[:space:]')
# Extract relay URLs from the JSON file
echo "Extracting relay URLs from $RELAYS_FILE..."
mapfile -t RELAYS < <(python3 -c "
import json
with open('$RELAYS_FILE') as f:
data = json.load(f)
for tag in data:
if isinstance(tag, list) and len(tag) >= 2 and tag[0] == 'r':
print(tag[1])
")
TOTAL=${#RELAYS[@]}
echo "Found $TOTAL relay URLs"
echo ""
CONTENT="Broadcast relay test $(date -u +%Y-%m-%dT%H:%M:%SZ) — testing relay reachability via nak"
echo "Test content: $CONTENT"
echo ""
# Initialize results file
{
echo "# Broadcast Relay Test Results"
echo "# Date: $(date -u)"
echo "# Total relays: $TOTAL"
echo "# Test content: $CONTENT"
echo ""
} > "$RESULTS_FILE"
SUCCESS=0
FAILED=0
COUNT=0
for RELAY in "${RELAYS[@]}"; do
COUNT=$((COUNT + 1))
[ -z "$RELAY" ] && continue
printf "[%d/%d] %s ... " "$COUNT" "$TOTAL" "$RELAY"
RESULT=$(timeout 15 nak event --sec "$SEC" -c "$CONTENT" "$RELAY" 2>&1) || RESULT="FAILED: exit code $?"
if echo "$RESULT" | grep -qi "success"; then
echo "OK"
echo "OK: $RELAY" >> "$RESULTS_FILE"
SUCCESS=$((SUCCESS + 1))
elif echo "$RESULT" | grep -qi "error\|failed\|refused\|timeout\|reject\|blocked"; then
echo "FAIL"
echo "FAIL: $RELAY$RESULT" >> "$RESULTS_FILE"
FAILED=$((FAILED + 1))
else
echo "UNKNOWN"
echo "UNKNOWN: $RELAY$RESULT" >> "$RESULTS_FILE"
fi
done
echo ""
echo "=== Summary ==="
echo "Total: $TOTAL"
echo "Success: $SUCCESS"
echo "Failed: $FAILED"
echo ""
echo "Full results: $RESULTS_FILE"

View File

@@ -0,0 +1,660 @@
Extracting relay URLs from /home/user/lt/client/tests/relays.json...
Found 648 relay URLs
Test content: Broadcast relay test 2026-06-30T18:29:37Z — testing relay reachability via nak
[1/648] wss://nos.lol/ ... OK
[2/648] wss://relay.primal.net/ ... OK
[3/648] wss://nostr.wine/ ... FAIL
[4/648] wss://relay.snort.social/ ... OK
[5/648] wss://eden.nostr.land/ ... FAIL
[6/648] wss://relay.nostr.band/ ... FAIL
[7/648] wss://nostr.bitcoiner.social/ ... OK
[8/648] wss://purplepag.es/ ... FAIL
[9/648] wss://nostr.land/ ... FAIL
[10/648] wss://nostr-pub.wellorder.net/ ... OK
[11/648] wss://nostr.oxtr.dev/ ... OK
[12/648] wss://offchain.pub/ ... OK
[13/648] wss://relay.current.fyi/ ... FAIL
[14/648] wss://relay.nostr.bg/ ... FAIL
[15/648] wss://pyramid.fiatjaf.com/ ... FAIL
[16/648] wss://nostr.orangepill.dev/ ... FAIL
[17/648] wss://relay.ditto.pub/ ... OK
[18/648] wss://nostr.fmt.wiz.biz/ ... FAIL
[19/648] wss://brb.io/ ... FAIL
[20/648] wss://nostrelites.org/ ... FAIL
[21/648] wss://puravida.nostr.land/ ... FAIL
[22/648] wss://wot.utxo.one/ ... FAIL
[23/648] wss://nostr.mutinywallet.com/ ... FAIL
[24/648] wss://relay.nostr.info/ ... FAIL
[25/648] wss://nostr.milou.lol/ ... FAIL
[26/648] wss://relay.orangepill.dev/ ... FAIL
[27/648] wss://relayable.org/ ... FAIL
[28/648] wss://filter.nostr.wine/ ... FAIL
[29/648] wss://relay.nostrplebs.com/ ... FAIL
[30/648] wss://atlas.nostr.land/ ... FAIL
[31/648] wss://theforest.nostr1.com/ ... FAIL
[32/648] wss://nostr.zebedee.cloud/ ... FAIL
[33/648] wss://relay.0xchat.com/ ... OK
[34/648] wss://relay.nos.social/ ... OK
[35/648] wss://welcome.nostr.wine/ ... FAIL
[36/648] wss://bitcoiner.social/ ... FAIL
[37/648] wss://relay.noswhere.com/ ... OK
[38/648] wss://no.str.cr/ ... OK
[39/648] wss://nostr-pub.semisol.dev/ ... FAIL
[40/648] wss://nostr.einundzwanzig.space/ ... FAIL
[41/648] wss://relay.nostr.com.au/ ... FAIL
[42/648] wss://relay.fountain.fm/ ... OK
[43/648] wss://relay.nostrati.com/ ... FAIL
[44/648] wss://hist.nostr.land/ ... FAIL
[45/648] wss://nostr-relay.wlvs.space/ ... FAIL
[46/648] wss://nostr.onsats.org/ ... FAIL
[47/648] wss://relay.nostrcheck.me/ ... OK
[48/648] wss://nostr21.com/ ... FAIL
[49/648] wss://purplerelay.com/ ... OK
[50/648] wss://relay.bitcoinpark.com/ ... FAIL
[51/648] wss://relay.plebstr.com/ ... FAIL
[52/648] wss://wot.nostr.party/ ... FAIL
[53/648] wss://yabu.me/ ... FAIL
[54/648] wss://lightningrelay.com/ ... FAIL
[55/648] wss://relay.noderunners.network/ ... FAIL
[56/648] wss://spatia-arcana.com/ ... FAIL
[57/648] wss://140.f7z.io/ ... FAIL
[58/648] wss://nostr-01.yakihonne.com/ ... OK
[59/648] wss://nostr.inosta.cc/ ... FAIL
[60/648] wss://relay.f7z.io/ ... FAIL
[61/648] wss://relay.getalby.com/v1 ... FAIL
[62/648] wss://relay.momostr.pink/ ... FAIL
[63/648] wss://wot.sovbit.host/ ... FAIL
[64/648] wss://aggr.nostr.land/ ... FAIL
[65/648] wss://bitcoinmaximalists.online/ ... FAIL
[66/648] wss://nostr.plebchain.org/ ... FAIL
[67/648] wss://algo.utxo.one/ ... FAIL
[68/648] wss://at.nostrworks.com/ ... FAIL
[69/648] wss://bevo.nostr1.com/ ... FAIL
[70/648] wss://nostr.bitcoinplebs.de/ ... FAIL
[71/648] wss://nostr.relayer.se/ ... FAIL
[72/648] wss://nostr.rocks/ ... FAIL
[73/648] wss://nostr.walletofsatoshi.com/ ... FAIL
[74/648] wss://relay.stoner.com/ ... FAIL
[75/648] wss://relay.utxo.one/ ... FAIL
[76/648] wss://rsslay.nostr.net/ ... FAIL
[77/648] ws://umbrel.local:4848/ ... FAIL
[78/648] wss://auth.nostr1.com/ ... FAIL
[79/648] wss://bitsat.molonlabe.holdings/ ... FAIL
[80/648] wss://creatr.nostr.wine/ ... FAIL
[81/648] wss://nostr.sethforprivacy.com/ ... FAIL
[82/648] wss://nostr.slothy.win/ ... FAIL
[83/648] wss://nostr.v0l.io/ ... FAIL
[84/648] wss://nostr.zbd.gg/ ... FAIL
[85/648] wss://relay.azzamo.net/ ... FAIL
[86/648] wss://relay.nostr.net/ ... OK
[87/648] wss://relay.nostriches.org/ ... FAIL
[88/648] wss://relay.nostrview.com/ ... FAIL
[89/648] wss://wot.girino.org/ ... FAIL
[90/648] ws://127.0.0.1:4869/ ... FAIL
[91/648] wss://e.nos.lol/ ... FAIL
[92/648] wss://nostr-02.yakihonne.com/ ... OK
[93/648] wss://nostr.cercatrova.me/ ... FAIL
[94/648] wss://nostr.chaima.info/ ... OK
[95/648] wss://nostr.cypherpunk.today/ ... FAIL
[96/648] wss://nostr.portemonero.com/ ... FAIL
[97/648] wss://nostr.sandwich.farm/ ... FAIL
[98/648] wss://nostr.thesamecat.io/ ... FAIL
[99/648] wss://nostrue.com/ ... FAIL
[100/648] wss://relay-jp.nostr.wirednet.jp/ ... FAIL
[101/648] wss://relay.lnau.net/ ... FAIL
[102/648] wss://relay.siamstr.com/ ... FAIL
[103/648] wss://relay.wavlake.com/ ... OK
[104/648] wss://satsage.xyz/ ... FAIL
[105/648] wss://wheat.happytavern.co/ ... OK
[106/648] wss://wot.sudocarlos.com/ ... FAIL
[107/648] wss://basspistol.org/ ... FAIL
[108/648] wss://cellar.nostr.wine/ ... FAIL
[109/648] wss://feeds.nostr.band/popular ... FAIL
[110/648] wss://freelay.sovbit.host/ ... OK
[111/648] wss://inbox.azzamo.net/ ... FAIL
[112/648] wss://inbox.nostr.wine/ ... FAIL
[113/648] wss://lightning.red/ ... FAIL
[114/648] wss://nfdb.noswhere.com/ ... FAIL
[115/648] wss://nostr-03.dorafactory.org/ ... FAIL
[116/648] wss://nostr-relay.derekross.me/ ... FAIL
[117/648] wss://nostr-relay.digitalmob.ro/ ... FAIL
[118/648] wss://nostr.pleb.network/ ... FAIL
[119/648] wss://nostr.semisol.dev/ ... FAIL
[120/648] wss://nostr.thank.eu/ ... FAIL
[121/648] wss://nwc.primal.net/ayvjleilmx0al7j2pqt24qed1z7a8s ... FAIL
[122/648] wss://orangepiller.org/ ... FAIL
[123/648] wss://relay.44billion.net/ ... OK
[124/648] wss://relay.divine.video/ ... OK
[125/648] wss://relay.mutinywallet.com/ ... FAIL
[126/648] wss://relay.nostr.ch/ ... FAIL
[127/648] wss://relay.nostr.nu/ ... FAIL
[128/648] wss://relay.otherstuff.fyi/ ... FAIL
[129/648] wss://relay.routstr.com/ ... OK
[130/648] wss://relay.shitforce.one/ ... FAIL
[131/648] wss://relay.utxo.one/outbox ... FAIL
[132/648] wss://rilo.nostria.app/ ... OK
[133/648] wss://search.nos.today/ ... FAIL
[134/648] wss://wot.nostr.net/ ... FAIL
[135/648] wss://wot.shaving.kiwi/ ... FAIL
[136/648] wss://wot.siamstr.com/ ... FAIL
[137/648] wss://xmr.usenostr.org/ ... FAIL
[138/648] wss://zap.watch/ ... FAIL
[139/648] ws://127.0.0.1:8888/ ... FAIL
[140/648] wss://adult.18plus.social/ ... FAIL
[141/648] wss://anon.computer/ ... FAIL
[142/648] wss://bostr.azzamo.net/ ... FAIL
[143/648] wss://chorus.mikedilger.com:444/ ... FAIL
[144/648] wss://deschooling.us/ ... FAIL
[145/648] wss://ditto.pub/relay ... OK
[146/648] wss://feeds.nostr.band/typescript ... FAIL
[147/648] wss://filter.nostr.wine/npub1s5yq6wadwrxde4lhfs56gn64hwzuhnfa6r9mj476r5s4hkunzgzqrs6q7z ... FAIL
[148/648] wss://global-relay.cesc.trade/ ... FAIL
[149/648] wss://greensoul.space/ ... FAIL
[150/648] wss://haven.calva.dev/ ... FAIL
[151/648] wss://indexer.coracle.social/ ... FAIL
[152/648] wss://lunchbox.sandwich.farm/ ... FAIL
[153/648] wss://me.purplerelay.com/ ... FAIL
[154/648] wss://misskey.04.si/ ... FAIL
[155/648] wss://news.utxo.one/ ... FAIL
[156/648] wss://nostr-02.dorafactory.org/ ... FAIL
[157/648] wss://nostr-1.nbo.angani.co/ ... FAIL
[158/648] wss://nostr-2.zebedee.cloud/ ... FAIL
[159/648] wss://nostr-dev.wellorder.net/ ... OK
[160/648] wss://nostr-relay.bitcoin.ninja/ ... FAIL
[161/648] wss://nostr-relay.nokotaro.com/ ... FAIL
[162/648] wss://nostr.0x7e.xyz/ ... OK
[163/648] wss://nostr.88mph.life/ ... OK
[164/648] wss://nostr.azte.co/ ... FAIL
[165/648] wss://nostr.azzamo.net/ ... OK
[166/648] wss://nostr.bitpunk.fm/ ... FAIL
[167/648] wss://nostr.data.haus/ ... OK
[168/648] wss://nostr.lopp.social/ ... FAIL
[169/648] wss://nostr.lorentz.is/ ... FAIL
[170/648] wss://nostr.lu.ke/ ... FAIL
[171/648] wss://nostr.roundrockbitcoiners.com/ ... FAIL
[172/648] wss://nostr.sebastix.dev/ ... FAIL
[173/648] wss://nostr.self-determined.de/ ... FAIL
[174/648] wss://nostr.sovbit.host/ ... FAIL
[175/648] wss://nostr.stakey.net/ ... FAIL
[176/648] wss://nostr.tavux.tech/ ... FAIL
[177/648] wss://nostr.vulpem.com/ ... OK
[178/648] wss://nostr.xmr.rocks/ ... FAIL
[179/648] wss://nostr01.sharkshake.net/ ... FAIL
[180/648] wss://nostrcheck.me/relay ... OK
[181/648] wss://nostrsatva.net/ ... FAIL
[182/648] wss://nrelay.c-stellar.net/ ... FAIL
[183/648] wss://paid.no.str.cr/ ... FAIL
[184/648] wss://public.nostr.swissrouting.com/ ... FAIL
[185/648] wss://r.hostr.cc/ ... FAIL
[186/648] wss://relay.bitblockboom.com/ ... FAIL
[187/648] wss://relay.bitcoindistrict.org/ ... FAIL
[188/648] wss://relay.bullishbounty.com/ ... OK
[189/648] wss://relay.cashumints.space/ ... FAIL
[190/648] wss://relay.coinos.io/ ... OK
[191/648] wss://relay.damus.com/ ... FAIL
[192/648] wss://relay.dergigi.com/ ... FAIL
[193/648] wss://relay.dreamith.to/ ... OK
[194/648] wss://relay.exit.pub/ ... FAIL
[195/648] wss://relay.gasteazi.net/ ... OK
[196/648] wss://relay.geekiam.services/ ... FAIL
[197/648] wss://relay.getsafebox.app/ ... OK
[198/648] wss://relay.kamp.site/ ... FAIL
[199/648] wss://relay.martien.io/ ... FAIL
[200/648] wss://relay.nosflare.com/ ... FAIL
[201/648] wss://relay.nostr.band/all ... FAIL
[202/648] wss://relay.nostr.hu/ ... FAIL
[203/648] wss://relay.nostr.pro/ ... FAIL
[204/648] wss://relay.nostr.wirednet.jp/ ... OK
[205/648] wss://relay.nostrgraph.net/ ... FAIL
[206/648] wss://relay.nostrica.com/ ... FAIL
[207/648] wss://relay.nostrich.de/ ... FAIL
[208/648] wss://relay.nostrss.re/ ... FAIL
[209/648] wss://relay.notoshi.win/ ... OK
[210/648] wss://relay.nsecbunker.com/ ... FAIL
[211/648] wss://relay.s-w.art/ ... FAIL
[212/648] wss://relay.sigit.io/ ... OK
[213/648] wss://relay.sovbit.host/ ... FAIL
[214/648] wss://relay.sovereignengineering.io/ ... FAIL
[215/648] wss://relay.vertexlab.io/ ... FAIL
[216/648] wss://relay.wellorder.net/ ... OK
[217/648] wss://relay.westernbtc.com/ ... FAIL
[218/648] wss://relayer.fiatjaf.com/ ... FAIL
[219/648] wss://ribo.eu.nostria.app/ ... OK
[220/648] wss://ribo.nostria.app/ ... OK
[221/648] wss://temp.iris.to/ ... OK
[222/648] wss://thebarn.nostr1.com/ ... FAIL
[223/648] wss://tigs.nostr1.com/ ... FAIL
[224/648] wss://vault.iris.to/ ... OK
[225/648] wss://vitor.nostr1.com/ ... FAIL
[226/648] wss://xmr.ithurtswhenip.ee/ ... FAIL
[227/648] ⬤ wss://nostr-pub.wellorder.net ... FAIL
[228/648] coracle ... FAIL
[229/648] https://sovbitm2enxfr5ot6qscwy5ermdffbqscy66wirkbsigvcshumyzbbqd.onion/ ... FAIL
[230/648] ws://192.168.1.63:4848/ ... FAIL
[231/648] ws://2tkf2psfhves3tp2izcchzc5gpiowk4gzkcqls4l46wj5v4b772tyead.onion/ ... FAIL
[232/648] ws://bitcoin.anneca.cz:4848/ ... FAIL
[233/648] ws://eden.nostr.land/ ... FAIL
[234/648] ws://ehsc3vsoqugf5dyzvop2o4ii7nos3yccirirlxmycx4hle5epbxppnad.onion/ ... FAIL
[235/648] ws://k6dpciogx4fabnipku6wlce4rjv3ffjhv6gcundcxvxn6poeq2hcn3id.onion/ ... FAIL
[236/648] ws://localhost:4869/ ... FAIL
[237/648] ws://qwqk4b6royrrgmk4pvoccok4wt5fcvpulwnse5656bcqt3bncokpt3qd.onion/ ... FAIL
[238/648] ws://relay.jb55.com/ ... FAIL
[239/648] ws://w3xtwz6dc7krqkkm22odcvpdpc5escq7lg266quupdyatxr5gjcm55ad.onion/ ... FAIL
[240/648] ws://willem.currycash.net:4848/ ... FAIL
[241/648] wss://1.noztr.com/ ... FAIL
[242/648] wss://2jsnlhfnelig5acq6iacydmzdbdmg7xwunm4xl6qwbvzacw4lwrjmlyd.onion/ ... FAIL
[243/648] wss://64.23.224.231:54711/ ... FAIL
[244/648] wss://adfasfasfadsdfasfasf3123412ewfas.xyz/ ... FAIL
[245/648] wss://adre.su/ ... FAIL
[246/648] wss://ae.purplerelay.com/ ... FAIL
[247/648] wss://ae3t4h2.oops.wtf/ ... FAIL
[248/648] wss://aegis.utxo.one/ ... FAIL
[249/648] wss://alphapanda.pro/ ... FAIL
[250/648] wss://art.nostrfreaks.com/ ... FAIL
[251/648] wss://asia.azzamo.net/ ... FAIL
[252/648] wss://atlas.nostr.land/invoices ... FAIL
[253/648] wss://au.relayable.org/ ... FAIL
[254/648] wss://bitcoinmajlis.nostr1.com/ ... FAIL
[255/648] wss://bitcoinostr.duckdns.org/ ... OK
[256/648] wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion/ ... FAIL
[257/648] wss://bitstack.app/ ... FAIL
[258/648] wss://catstrr.swarmstr.com/ ... FAIL
[259/648] wss://christpill.nostr1.com/ ... FAIL
[260/648] wss://ciao.rinbal.de/ ... FAIL
[261/648] wss://cyberspace.nostr1.com/ ... OK
[262/648] wss://db4efobus2ilttw5gokcwck3okcr476hqkmyqduoyydlo2j7iigehkyd.local/ ... FAIL
[263/648] wss://directory.yabu.me/alpha-ember ... FAIL
[264/648] wss://echo.websocket.org/ ... FAIL
[265/648] wss://eclipse.pub/relay ... FAIL
[266/648] wss://ehsc3vsoqugf5dyzvop2o4ii7nos3yccirirlxmycx4hle5epbxppnad.local/ ... FAIL
[267/648] wss://elites.nostrati.org/ ... FAIL
[268/648] wss://espelho.girino.org/ ... FAIL
[269/648] wss://essayist.decentnewsroom.com/ ... FAIL
[270/648] wss://eu.nostr.pikachat.org/juliet-alpha-nexus ... FAIL
[271/648] wss://eu.rbr.bio/ ... FAIL
[272/648] wss://eyes.f7z.io/ ... FAIL
[273/648] wss://fabian.nostr1.com/ ... FAIL
[274/648] wss://fanfares.nostr1.com/ ... OK
[275/648] wss://feeds.nostr.band/omni__ventures ... FAIL
[276/648] wss://feeds.nostr.band/pics ... FAIL
[277/648] wss://fiatjaf.nostr1.com/ ... FAIL
[278/648] wss://filter.nostr.wine/?global=all ... FAIL
[279/648] wss://filter.nostr.wine/npub12gu8c6uee3p243gez6cgk76362admlqe72aq3kp2fppjsjwmm7eqj9fle6?broadcast=true ... FAIL
[280/648] wss://filter.nostr.wine/npub14yzxejghthz6ghae8gkgjru63vvvwpl6d454wud2hycqpqwnugdqcutuw5?broadcast=true ... FAIL
[281/648] wss://filter.nostr.wine/npub153xmex42x4chdf757hp3q6zxagykkek7pdgwuwd074964dkyha9s82ryu8?broadcast=true ... FAIL
[282/648] wss://filter.nostr.wine/npub16w4nxxv7kjxx77zsw262v65w27q5udwnzd6ue2xremhvc9clxzaq974vef?broadcast=true ... FAIL
[283/648] wss://filter.nostr.wine/npub17xu3rtcu0ftqw03mswa8a2ngz3nsgrs0h0wjv4z942qwvhp8fs3qzcadls?broadcast=true ... FAIL
[284/648] wss://filter.nostr.wine/npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424?broadcast=true ... FAIL
[285/648] wss://filter.nostr.wine/npub1a6zkqnuwcmjwynuw4u4xyngy9675x8dwgj87z9me4h8mdwmc2a0q8mvhjk?broadcast=true ... FAIL
[286/648] wss://filter.nostr.wine/npub1cuyfg04rf9gemn6kk2juzw8anmgxftt9mhk2ucu5a27c0f30zacqggzw8d?broadcast=true ... FAIL
[287/648] wss://filter.nostr.wine/npub1du6sgl90wse0cz44fg50a4kg9ea4sgctlxps90ccx58lw8ssgv9qhjyf3c?broadcast=true ... FAIL
[288/648] wss://filter.nostr.wine/npub1ngumlqmus6xkrmvvee4yc7swh9h4uk7vpq4ddt7a2jtvkc22y0asrse3pv?broadcast=true ... FAIL
[289/648] wss://filter.nostr.wine/npub1nh4z0p2ewjsgln4533q04wzr9sdguwjn58dz9gdd26zet4spqeysktrh05?broadcast=true ... FAIL
[290/648] wss://filter.nostr.wine/npub1p4kg8zxukpym3h20erfa3samj00rm2gt4q5wfuyu3tg0x3jg3gesvncxf8 ... FAIL
[291/648] wss://filter.nostr.wine/npub1qjgcmlpkeyl8mdkvp4s0xls4ytcux6my606tgfx9xttut907h0zs76lgjw?broadcast=true ... FAIL
[292/648] wss://filter.nostr.wine/npub1rr654u0pp3dm0g65dzc0v2eft5frg7gre8u4wwxsvhyyhmc5qths50eeul?broadcast=true ... FAIL
[293/648] wss://filter.nostr.wine/npub1rtlqca8r6auyaw5n5h3l5422dm4sry5dzfee4696fqe8s6qgudks7djtfs?broadcast=true ... FAIL
[294/648] wss://filter.nostr.wine/npub1sjjz60h6fqqcuxrsyl3thhgpx2z6ylv047tslqar26ga0chp5vgq7404nu?broadcast=true ... FAIL
[295/648] wss://filter.nostr.wine/npub1t0nyg64g5vwprva52wlcmt7fkdr07v5dr7s35raq9g0xgc0k4xcsedjgqv?broadcast=true ... FAIL
[296/648] wss://filter.nostr.wine/npub1t5wc8h37uhkau9tsw82sjxndq04d3n8p634utpdfvs4tm5xmt2sqgk6dke?broadcast=true ... FAIL
[297/648] wss://filter.nostr.wine/npub1xv6axulxcx6mce5mfvfzpsy89r4gee3zuknulm45cqqpmyw7680q5pxea6?broadcast=true ... FAIL
[298/648] wss://filter.nostr.wine/npub1yaul8k059377u9lsu67de7y637w4jtgeuwcmh5n7788l6xnlnrgs3tvjmf?broadcast=true ... FAIL
[299/648] wss://filter.nostr.wine/npub1zqsu3ys4fragn2a5e3lgv69r4rwwhts2fserll402uzr3qeddxfsffcqrs?broadcast=true ... FAIL
[300/648] wss://foton.solarpowe.red/ ... FAIL
[301/648] wss://frens.nostr1.com/ ... FAIL
[302/648] wss://frens.utxo.one/ ... FAIL
[303/648] wss://futarchyhub.com/relay ... FAIL
[304/648] wss://galaxy13.nostr1.com/ ... FAIL
[305/648] wss://garden.zap.cooking/ ... FAIL
[306/648] wss://gleasonator.dev/relay ... OK
[307/648] wss://gm.swarmstr.com/ ... FAIL
[308/648] wss://grace.ikaros.hzrd149.com/ ... FAIL
[309/648] wss://groups.0xchat.com/ ... FAIL
[310/648] wss://haven.dergigi.com/ ... FAIL
[311/648] wss://haven.downisontheup.ca/ ... FAIL
[312/648] wss://haven.slidestr.net/ ... FAIL
[313/648] wss://haven.sovereignengineering.io/outbox ... FAIL
[314/648] wss://hbr.coracle.social/ ... FAIL
[315/648] wss://henhouse.social/relay ... FAIL
[316/648] wss://hodlbod.coracle.social/ ... FAIL
[317/648] wss://hodlbod.coracle.tools/ ... FAIL
[318/648] wss://HodlHarry@nostrcheck.me/ ... OK
[319/648] wss://hole.v0l.io/ ... FAIL
[320/648] wss://hotrightnow.nostr1.com/ ... FAIL
[321/648] wss://https//wot.utxo.one/# ... FAIL
[322/648] wss://inbox.relays.land/ ... FAIL
[323/648] wss://inner.sebastix.social/ ... FAIL
[324/648] wss://iris.to/ ... FAIL
[325/648] wss://kmc-nostr.amiunderwater.com/ ... FAIL
[326/648] wss://knostr.neutrine.com:8880/ ... FAIL
[327/648] wss://knostr.neutrine.com/ ... FAIL
[328/648] wss://kr.purplerelay.com/ ... FAIL
[329/648] wss://librerelay.aaroniumii.com/ ... FAIL
[330/648] wss://ln.weedstr.net/nostrrelay/weedstr ... FAIL
[331/648] wss://lnb.bolverker.com/nostrrelay/666 ... FAIL
[332/648] wss://lockbox.fiatjaf.com/ ... FAIL
[333/648] wss://lv01.tater.ninja/ ... FAIL
[334/648] wss://moonboi.nostrfreaks.com/ ... FAIL
[335/648] wss://muxstr.northwest.io/ ... FAIL
[336/648] wss://nerostr.xmr.rocks/ ... FAIL
[337/648] wss://news.nos.social/ ... FAIL
[338/648] wss://nfdb.noswhere.com/victor ... FAIL
[339/648] wss://nip17.com/ ... FAIL
[340/648] wss://njump.me/ ... FAIL
[341/648] wss://nos.win/ ... FAIL
[342/648] wss://nostr-01.bolt.observer/ ... FAIL
[343/648] wss://nostr-paid.h3z.jp/ ... FAIL
[344/648] wss://nostr-relay.amethyst.name/ ... FAIL
[345/648] wss://nostr-relay.amethyst.name/jade-raven-juliet ... FAIL
[346/648] wss://nostr-relay.feddit.social/ ... FAIL
[347/648] wss://nostr-relay.lnmarkets.com/ ... FAIL
[348/648] wss://nostr-relay.philipcristiano.com/ ... FAIL
[349/648] wss://nostr-relay.rawrapp.workers.dev/ ... FAIL
[350/648] wss://nostr-relay.schnitzel.world/ ... FAIL
[351/648] wss://nostr-relay.untethr.me/ ... FAIL
[352/648] wss://nostr-relay.usebitcoin.space/ ... FAIL
[353/648] wss://nostr-verified.wellorder.net/ ... OK
[354/648] wss://nostr.1sat.org/ ... FAIL
[355/648] wss://nostr.256k1.dev/ ... FAIL
[356/648] wss://nostr.438b.net/ ... FAIL
[357/648] wss://nostr.4rs.nl/ ... OK
[358/648] wss://nostr.8777.ch/ ... FAIL
[359/648] wss://nostr.actn.io/ ... FAIL
[360/648] wss://nostr.app.runonflux.io/ ... OK
[361/648] wss://nostr.at/ ... FAIL
[362/648] wss://nostr.azuki.blue/ember-ivory-mike ... FAIL
[363/648] wss://nostr.azzamo.net/flint-warden ... OK
[364/648] wss://nostr.beckmeyer.us/ ... FAIL
[365/648] wss://nostr.bitcoin.ninja/ ... FAIL
[366/648] wss://nostr.bitcoin.social/ ... FAIL
[367/648] wss://nostr.bostonbtc.com/ ... FAIL
[368/648] wss://nostr.build/ ... FAIL
[369/648] wss://nostr.cizmar.net/ ... FAIL
[370/648] wss://nostr.cloud.vinney.xyz/ ... FAIL
[371/648] wss://nostr.coinfundit.com/ ... FAIL
[372/648] wss://nostr.compile-error.net/ ... FAIL
[373/648] wss://nostr.cx.ms/ ... FAIL
[374/648] wss://nostr.delo.software/ ... FAIL
[375/648] wss://nostr.digitalreformation.info/ ... FAIL
[376/648] wss://nostr.dontyou.click/oscar-warden-flint ... FAIL
[377/648] wss://nostr.drss.io/ ... FAIL
[378/648] wss://nostr.dvdt.dev/ ... FAIL
[379/648] wss://nostr.easify.de/ ... FAIL
[380/648] wss://nostr.easydns.ca/ ... FAIL
[381/648] wss://nostr.extrabits.io/ ... FAIL
[382/648] wss://nostr.fediverse.jp/ ... FAIL
[383/648] wss://nostr.flamingo-mail.com/ ... FAIL
[384/648] wss://nostr.gives.africa/ ... FAIL
[385/648] wss://nostr.gromeul.eu/ ... FAIL
[386/648] wss://nostr.holybea.com/ ... FAIL
[387/648] wss://nostr.huszonegy.world/ ... OK
[388/648] wss://nostr.ingwie.me/ ... FAIL
[389/648] wss://nostr.jiashanlu.synology.me/ ... FAIL
[390/648] wss://nostr.k3tan.com/ ... FAIL
[391/648] wss://nostr.koning-degraaf.nl/ ... FAIL
[392/648] wss://nostr.kosmos.org/ ... FAIL
[393/648] wss://nostr.land/xray-xray-cipher ... FAIL
[394/648] wss://nostr.lnproxy.org/ ... FAIL
[395/648] wss://nostr.malin.onl/ ... FAIL
[396/648] wss://nostr.massmux.com/ ... FAIL
[397/648] wss://nostr.middling.mydns.jp/ ... FAIL
[398/648] wss://nostr.mineracks.com/ ... OK
[399/648] wss://nostr.noones.com/ ... FAIL
[400/648] wss://nostr.novacisko.cz/ ... FAIL
[401/648] wss://nostr.okaits7534.net/ ... FAIL
[402/648] wss://nostr.oldenburg.cool/ ... FAIL
[403/648] wss://nostr.openhoofd.nl/ ... OK
[404/648] wss://nostr.ownscale.org/ ... FAIL
[405/648] wss://nostr.palandi.cloud/ ... FAIL
[406/648] wss://nostr.pareto.town/ ... FAIL
[407/648] wss://nostr.petrkr.net/willow ... FAIL
[408/648] wss://nostr.primz.org/charlie-xenon-bravo ... FAIL
[409/648] wss://nostr.radixrat.com/ ... FAIL
[410/648] wss://nostr.robosats.org/ ... FAIL
[411/648] wss://nostr.sathoarder.com/titan-glyph ... OK
[412/648] wss://nostr.sats.coffee/ ... FAIL
[413/648] wss://nostr.sats.li/ ... FAIL
[414/648] wss://nostr.screaminglife.io/ ... FAIL
[415/648] wss://nostr.sectiontwo.org/ ... FAIL
[416/648] wss://nostr.sg/ ... FAIL
[417/648] wss://nostr.sixteensixtyone.com/ ... FAIL
[418/648] wss://nostr.stoner.com/ ... FAIL
[419/648] wss://nostr.superfriends.online/titan ... OK
[420/648] wss://nostr.supremestack.xyz/ ... FAIL
[421/648] wss://nostr.swiss-enigma.ch/ ... FAIL
[422/648] wss://nostr.terminus.money/ ... FAIL
[423/648] wss://nostr.topeth.info/ ... FAIL
[424/648] wss://nostr.uselessshit.co/ ... FAIL
[425/648] wss://nostr.yuv.al/ ... FAIL
[426/648] wss://nostr.zoel.network/ ... FAIL
[427/648] wss://nostr01.counterclockwise.io/ ... FAIL
[428/648] wss://nostr1.tunnelsats.com/ ... FAIL
[429/648] wss://nostr1676319567170.app.runonflux.io/ ... FAIL
[430/648] wss://nostr21.com/raven ... FAIL
[431/648] wss://nostrcheck.tnsor.network/ ... FAIL
[432/648] wss://nostrelay.circum.space/ ... OK
[433/648] wss://nostrelay.unchainedhome.com/ ... OK
[434/648] wss://nostrex.fly.dev/ ... FAIL
[435/648] wss://nostrrelay.com/ ... FAIL
[436/648] wss://nostrrelay.taylorperron.com/ ... FAIL
[437/648] wss://notify.damus.io/ ... FAIL
[438/648] wss://novoa.nagoya/ ... FAIL
[439/648] wss://now.lol/ ... FAIL
[440/648] wss://npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network/ ... FAIL
[441/648] wss://nsite.run/ ... FAIL
[442/648] wss://nsrelay.assilvestrar.club/ ... FAIL
[443/648] wss://nstr.utn.lol/ ... FAIL
[444/648] wss://nwc.primal.net/c4ib8h2rs62bkmlk4wu7jbko9ugqbn ... FAIL
[445/648] wss://onchain.pub/ ... FAIL
[446/648] wss://paid-relay.nostr.blockhenge.com/ ... FAIL
[447/648] wss://paid.nostrified.org/ ... FAIL
[448/648] wss://pleb.cloud/ ... FAIL
[449/648] wss://primal-cache.mutinywallet.com/v1 ... FAIL
[450/648] wss://prl.plus/ ... OK
[451/648] wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87 ... FAIL
[452/648] wss://proxy.nostr-relay.app/8c5723f2601334234e1922d2e842d6bbf209283b07120b3f1d38660915f13793 ... FAIL
[453/648] wss://public.crostr.com/beacon-umbra ... OK
[454/648] wss://public.relaying.io/ ... FAIL
[455/648] wss://questions.swarmstr.com/ ... FAIL
[456/648] wss://r.f7z.io/ ... FAIL
[457/648] wss://r.kojira.io/ ... FAIL
[458/648] wss://r314y.0xd43m0n.xyz/ ... FAIL
[459/648] wss://ragnar-relay.com/ ... FAIL
[460/648] wss://raley.azzamo.net/ ... FAIL
[461/648] wss://realy.damus.io/ ... FAIL
[462/648] wss://realy.nostr.bg/ ... FAIL
[463/648] wss://relais.noswhere.com/ ... FAIL
[464/648] wss://relay-1.arsip.my.id/ ... FAIL
[465/648] wss://relay-rpi.edufeed.org/ ... OK
[466/648] wss://relay-testnet.k8s.layer3.news/xenon-anchor ... OK
[467/648] wss://relay.2020117.xyz/oscar-lima ... FAIL
[468/648] wss://relay.21mil.me/ ... FAIL
[469/648] wss://relay.8333.space/ ... FAIL
[470/648] wss://relay.angor.io/ ... OK
[471/648] wss://relay.apps.sebdev.io/ ... FAIL
[472/648] wss://relay.artx.market/ ... OK
[473/648] wss://relay.austrich.net/ ... FAIL
[474/648] wss://relay.benthecarman.com/ ... FAIL
[475/648] wss://relay.bitesize-media.com/ ... FAIL
[476/648] wss://relay.bitmapstr.io/ ... FAIL
[477/648] wss://relay.bleskop.com/ ... FAIL
[478/648] wss://relay.braydon.com/ ... FAIL
[479/648] wss://relay.carlos-cdb.top/vertex-whiskey ... OK
[480/648] wss://relay.causes.com/ ... FAIL
[481/648] wss://relay.codl.co/ ... FAIL
[482/648] wss://relay.corpum.com/ ... OK
[483/648] wss://relay.cryptocculture.com/ ... FAIL
[484/648] wss://relay.deezy.io/ ... FAIL
[485/648] wss://relay.despera.space/ ... FAIL
[486/648] wss://relay.devstr.org/ ... FAIL
[487/648] wss://relay.digitalezukunft.cyou/ ... FAIL
[488/648] wss://relay.disobey.dev/ ... FAIL
[489/648] wss://relay.dwadziesciajeden.pl/ ... OK
[490/648] wss://relay.getalby.com/ ... FAIL
[491/648] wss://relay.geyser.fund/ ... FAIL
[492/648] wss://relay.gobrrr.me/ ... FAIL
[493/648] wss://relay.goodmorningbitcoin.com/ ... FAIL
[494/648] wss://relay.gulugulu.moe/ ... OK
[495/648] wss://relay.hangar.ninja/ ... FAIL
[496/648] wss://relay.hash.stream/ ... FAIL
[497/648] wss://relay.highlighter.com/ ... FAIL
[498/648] wss://relay.hodlboard.org/ ... FAIL
[499/648] wss://relay.hunos.hu/ ... FAIL
[500/648] wss://relay.iefan.net/ ... FAIL
[501/648] wss://relay.ingwie.me/ ... FAIL
[502/648] wss://relay.islandbitcoin.com/echo-anchor-cipher ... OK
[503/648] wss://relay.jeffg.fyi/ ... FAIL
[504/648] wss://relay.jerseyplebs.com/ ... FAIL
[505/648] wss://relay.kisiel.net.pl/ ... FAIL
[506/648] wss://relay.lab.rytswd.com/karma-titan-lantern ... OK
[507/648] wss://relay.lacompagniemaximus.com/romeo-yonder ... FAIL
[508/648] wss://relay.layer.systems/ ... FAIL
[509/648] wss://relay.lexingtonbitcoin.org/ ... FAIL
[510/648] wss://relay.lightning.pub/yankee ... OK
[511/648] wss://relay.livefreebtc.dev/ ... FAIL
[512/648] wss://relay.magiccity.live/ ... FAIL
[513/648] wss://relay.mccormick.cx/quartz ... OK
[514/648] wss://relay.mess.ch/ ... FAIL
[515/648] wss://relay.minds.com/nostr/v1/ws ... FAIL
[516/648] wss://relay.mostro.network/ ... OK
[517/648] wss://relay.nextblock.city/ ... FAIL
[518/648] wss://relay.nimo.cash/ ... FAIL
[519/648] wss://relay.nodana.app/ ... FAIL
[520/648] wss://relay.nomus.io/ ... OK
[521/648] wss://relay.nosotros.app/ ... FAIL
[522/648] wss://relay.nostpy.lol/ ... FAIL
[523/648] wss://relay.nostr.ai/ ... FAIL
[524/648] wss://relay.nostr.band/trusted ... FAIL
[525/648] wss://relay.nostr.blockhenge.com/ ... OK
[526/648] wss://relay.nostr.directory/ ... FAIL
[527/648] wss://relay.nostr.moe/ ... FAIL
[528/648] wss://relay.nostr.pub/ ... FAIL
[529/648] wss://relay.nostr.ro/ ... FAIL
[530/648] wss://relay.nostr.scot/ ... FAIL
[531/648] wss://relay.nostr.watch/ ... FAIL
[532/648] wss://relay.nostrarabia.com/ ... FAIL
[533/648] wss://relay.nostrasia.net/ ... FAIL
[534/648] wss://relay.nostreggs.io/ ... FAIL
[535/648] wss://relay.nostrich.land/ ... FAIL
[536/648] wss://relay.nostriches.or/ ... FAIL
[537/648] wss://relay.nostrid.com/ ... FAIL
[538/648] wss://relay.nostronautti.fi/ ... FAIL
[539/648] wss://relay.npubhaus.com/ ... OK
[540/648] wss://relay.nquiz.io/ ... FAIL
[541/648] wss://relay.nsec.app/ ... FAIL
[542/648] wss://relay.nsnip.io/ ... FAIL
[543/648] wss://relay.nstr.cc/ ... FAIL
[544/648] wss://relay.nuts.cash/ ... OK
[545/648] wss://relay.nvote.co/ ... FAIL
[546/648] wss://relay.oke.minds.io/nostr/v1/ws ... FAIL
[547/648] wss://relay.olas.app/ ... FAIL
[548/648] wss://relay.orangepilldev.com/ ... FAIL
[549/648] wss://relay.orangesync.tech/ ... FAIL
[550/648] wss://relay.orly.dev/ ... FAIL
[551/648] wss://relay.piazza.today/ ... FAIL
[552/648] wss://relay.pituf.in/ ... FAIL
[553/648] wss://relay.pleb.one/ ... FAIL
[554/648] wss://relay.plebeian.market/ ... OK
[555/648] wss://relay.pokernostr.com/ ... FAIL
[556/648] wss://relay.qstr.app/ ... OK
[557/648] wss://relay.reya.su/ ... FAIL
[558/648] wss://relay.rip/ ... FAIL
[559/648] wss://relay.rkus.se/ ... FAIL
[560/648] wss://relay.roygbiv.guide/ ... FAIL
[561/648] wss://relay.samt.st/ ... OK
[562/648] wss://relay.satlantis.io/ ... OK
[563/648] wss://relay.sendstr.com/ ... FAIL
[564/648] wss://relay.sharegap.net/ ... OK
[565/648] wss://relay.shawnyeager.com/ ... FAIL
[566/648] wss://relay.shawnyeager.com/chat ... FAIL
[567/648] wss://relay.shawnyeager.com/inbox ... FAIL
[568/648] wss://relay.shawnyeager.com/outbox ... FAIL
[569/648] wss://relay.shawnyeager.com/private ... FAIL
[570/648] wss://relay.shosho.live/ ... FAIL
[571/648] wss://relay.siamdev.cc/ ... FAIL
[572/648] wss://relay.snort.social:57/ ... FAIL
[573/648] wss://relay.sovereign.app/ ... FAIL
[574/648] wss://relay.sovrgn.co.za/ ... OK
[575/648] wss://relay.thibautduchene.fr/ ... FAIL
[576/648] wss://relay.tools/ ... FAIL
[577/648] wss://relay.towardsliberty.com/ ... FAIL
[578/648] wss://relay.tunestr.io/ ... FAIL
[579/648] wss://relay.utxo.one/chat ... FAIL
[580/648] wss://relay.utxo.one/inbox ... FAIL
[581/648] wss://relay.utxo.one/private ... FAIL
[582/648] wss://relay.valera.co/ ... FAIL
[583/648] wss://relay.varke.eu/ ... FAIL
[584/648] wss://relay.verified-nostr.com/ ... FAIL
[585/648] wss://relay.virginiafreedom.tech/ ... FAIL
[586/648] wss://relay.wavefunc.live/ ... OK
[587/648] wss://relay.wisp.talk/ ... OK
[588/648] wss://relay.xavierdamman.com/ember-quartz ... FAIL
[589/648] wss://relay.ynniv.com/ ... FAIL
[590/648] wss://relay.zap.stream/ ... FAIL
[591/648] wss://relay.zeh.app/ ... FAIL
[592/648] wss://relay.zhoushen929.com/ ... FAIL
[593/648] wss://relay1.orangesync.tech/ ... FAIL
[594/648] wss://relay2.blogstr.app/ ... FAIL
[595/648] wss://relay2.nostrasia.net/ ... FAIL
[596/648] wss://relay2.orangesync.tech/ ... FAIL
[597/648] wss://relay2.sovereignengineering.io/ ... FAIL
[598/648] wss://relay3.openvine.co/victor ... OK
[599/648] wss://ribo.us.nostria.app/ ... OK
[600/648] wss://rsslay.fiatjaf.com/ ... FAIL
[601/648] wss://rsslay.nostr.moe/ ... FAIL
[602/648] wss://rsslay.wss//relay.nostr.info%0b%20nostr.net ... FAIL
[603/648] wss://ryan.nostr1.com/ ... FAIL
[604/648] wss://santo.iguanatech.net/ ... FAIL
[605/648] wss://satdow.relaying.io/ ... FAIL
[606/648] wss://satstacker.cloud/ ... FAIL
[607/648] wss://sgl.rustcorp.com.au/ ... FAIL
[608/648] wss://snort.social/ ... FAIL
[609/648] wss://soloco.nl/ ... OK
[610/648] wss://srtrelay.c-stellar.net/ ... FAIL
[611/648] wss://srtrelay.c-stellar.net/karma-oscar-lima ... FAIL
[612/648] wss://strfry.bonsai.com/ivory-xenon-juliet ... OK
[613/648] wss://strfry.chatbett.de/ ... FAIL
[614/648] wss://strfry.iris.to/ ... FAIL
[615/648] wss://strfry.openhoofd.nl/ ... OK
[616/648] wss://student.chadpolytechnic.com/ ... FAIL
[617/648] wss://swiss.nostr.lc/ ... FAIL
[618/648] wss://testrelay.nostr1.com/ ... FAIL
[619/648] wss://thecitadel.nostr1.com/sierra ... FAIL
[620/648] wss://thewildhustle.nostr1.com/ ... FAIL
[621/648] wss://tijl.xyz/ ... FAIL
[622/648] wss://tmp-relay.cesc.trade/ ... FAIL
[623/648] wss://tw.purplerelay.com/ ... FAIL
[624/648] wss://umbrel.local:4848/ ... FAIL
[625/648] wss://umbrel.tail716804.ts.net:4848/ ... FAIL
[626/648] wss://universe.nostrich.land/ ... FAIL
[627/648] wss://us.azzamo.net/ ... FAIL
[628/648] wss://us.nostr.wine/ ... FAIL
[629/648] wss://us.purplerelay.com/ ... FAIL
[630/648] wss://us.rbr.bio/ ... FAIL
[631/648] wss://user.kindpag.es/ ... FAIL
[632/648] wss://wbc.nostr1.com/ember-xenon-whiskey ... FAIL
[633/648] wss://wons.calva.dev/ ... FAIL
[634/648] wss://wot.azzamo.net/ ... FAIL
[635/648] wss://wot.brightbolt.net/ ... FAIL
[636/648] wss://wot.codingarena.top/ ... FAIL
[637/648] wss://wot.dergigi.com/ ... FAIL
[638/648] wss://wot.sudoscarlos.com/ ... FAIL
[639/648] wss://wot.swarmstr.com/ ... FAIL
[640/648] wss://wot.tealeaf.dev/ ... FAIL
[641/648] wss://wot.zacoos.com/ ... FAIL
[642/648] wss://wotr.relatr.xyz/ ... FAIL
[643/648] wss://xn--%20wss-hn6d//nostr-pub.wellorder.net ... FAIL
[644/648] wss://yarnlady.21mil.me/ ... FAIL
[645/648] wss://yestr.me/ ... FAIL
[646/648] wss://ynostr.yael.at/tango ... FAIL
[647/648] wss://ynostr.yael.at/victor ... FAIL
[648/648] ww://relay.stoner.com ... FAIL
=== Summary ===
Total: 648
Success: 95
Failed: 553
Full results: /home/user/lt/client/tests/broadcast-relay-results.txt

2593
tests/relays.json Normal file

File diff suppressed because it is too large Load Diff

1
tests/test-key.txt Normal file
View File

@@ -0,0 +1 @@
539ac367da733f455a703242efeafbbb0750dda352e9a641e2ccc7aadb5a3e94

View File

@@ -179,6 +179,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
const MIN_BOOTSTRAP_POSTS = 10;
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
const MAX_STORED_POSTS = 500;
let currentPubkey = null;
let updateIntervalId = null;
@@ -449,11 +450,24 @@ import { initPostCards } from './js/post-interactions2.mjs';
}
}
function prunePostsArray() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
if (posts.length > MAX_STORED_POSTS) {
posts.length = MAX_STORED_POSTS;
postIds.clear();
posts.forEach((p) => postIds.add(p.id));
for (const id of Array.from(renderedPostIds)) {
if (!postIds.has(id)) renderedPostIds.delete(id);
}
}
}
function upsertFeedPost(evt) {
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
if (isEventMuted(evt)) return false;
postIds.add(evt.id);
posts.push(evt);
if (posts.length > MAX_STORED_POSTS + 50) prunePostsArray();
return true;
}
@@ -479,6 +493,62 @@ import { initPostCards } from './js/post-interactions2.mjs';
divFooterRight.textContent = `${posts.length} posts`;
}
function prependPostCard(post) {
if (!post?.id || renderedPostIds.has(post.id)) return false;
// If feed is empty or not yet rendered, fall back to full rebuild.
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
renderFeed();
return false;
}
const postCreatedAt = post.created_at || 0;
// Find the correct insertion point by scanning visible children's created_at.
// Posts are displayed newest-first, so we insert before the first child
// that is older than (or equal to) the new post.
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
let inserted = false;
for (const child of divFeed.children) {
const childId = child.getAttribute('data-post-id');
if (!childId) continue;
const childPost = posts.find((p) => p.id === childId);
if (!childPost) continue;
const childCreatedAt = childPost.created_at || 0;
if (postCreatedAt >= childCreatedAt) {
divFeed.insertBefore(postEl, child);
inserted = true;
break;
}
}
if (!inserted) {
// Post is older than all visible posts — append at the bottom.
divFeed.appendChild(postEl);
}
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.
while (divFeed.children.length > displayCount) {
const lastChild = divFeed.lastElementChild;
if (!lastChild) break;
const removedId = lastChild.getAttribute('data-post-id');
if (removedId) renderedPostIds.delete(removedId);
divFeed.removeChild(lastChild);
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = followedPubkeys.length > 0
? `${posts.length} posts from ${followedPubkeys.length} follows`
: 'Follow users to populate your feed.';
divFooterRight.textContent = `${posts.length} posts`;
return true;
}
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
if (!Array.isArray(authors) || authors.length === 0) return;
const filters = { kinds: [1], authors, limit };
@@ -499,7 +569,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
upsertFeedPost(evt1);
});
if (posts.length > 0) {
// Only do a full re-render if the feed hasn't been rendered yet.
// If the feed is already rendered, the posts are in the array and
// will appear when the user clicks "See More" or on next full render.
// This prevents the relay fetch callback from wiping prepended live posts.
if (posts.length > 0 && renderedPostIds.size === 0) {
renderFeed();
}
}).catch(() => {});
@@ -710,7 +784,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
return;
}
if (!upsertFeedPost(evt)) return;
renderFeed();
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
prependPostCard(evt);
} else {
renderFeed();
}
}
});

190
www/js/broadcast-relays.mjs Normal file
View File

@@ -0,0 +1,190 @@
/**
* Broadcast Relays Module (NIP-51 kind:10088)
*
* Thin wrapper around the worker-side broadcast relay state exposed via
* init-ndk.mjs. Mirrors the singleton/listener pattern of mute-list.mjs but
* delegates all persistence (NIP-44 encryption, kind 10088 publish, skip-mark
* coexistence with Amethyst) to the worker.
*
* Usage:
* import {
* loadBroadcastRelays, getActiveBroadcastRelays, getSkippedBroadcastRelays,
* addBroadcastRelay, removeBroadcastRelay, seedFromDiscovered,
* unskipRelay, onBroadcastRelaysChanged
* } from './broadcast-relays.mjs';
* await loadBroadcastRelays();
*/
import {
getBroadcastRelays,
saveBroadcastRelays,
seedBroadcastRelays,
unskipBroadcastRelay,
} from './init-ndk.mjs';
// ── Internal state ───────────────────────────────────────────────────────────
/** Set of active broadcast relay URLs (kind 10088 "r" tags). */
let active = new Set();
/** Map<url, { reason, timestamp }> of skip-marked relays (kind 10088 "x" tags). */
let skipped = new Map();
/** Change listeners. */
const listeners = new Set();
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Normalize a relay URL: lowercase host, ensure wss:// or ws:// prefix, strip
* trailing slash. Returns null if the input is not a usable URL.
*/
function normalizeRelayUrl(url) {
if (!url) return null;
let s = String(url).trim();
if (!s) return null;
if (!/^wss?:\/\//i.test(s)) {
// Default to wss:// when the user typed a bare host.
s = 'wss://' + s;
}
// Split scheme vs rest so we only lowercase the scheme/host portion.
const match = s.match(/^(wss?:\/\/)([^/]+)(.*)$/i);
if (!match) return null;
const scheme = match[1].toLowerCase();
const host = match[2].toLowerCase();
let path = match[3] || '';
// Strip trailing slash but keep root "/" paths intentional.
if (path.length > 0 && path.endsWith('/')) {
path = path.replace(/\/+$/, '');
}
return scheme + host + path;
}
// ── Load ─────────────────────────────────────────────────────────────────────
/**
* Fetch the current broadcast relay state from the worker and refresh local
* caches. Safe to call repeatedly (e.g. on sidenav open or cross-tab update).
*/
export async function loadBroadcastRelays() {
try {
const result = await getBroadcastRelays();
active = new Set(Array.isArray(result?.active) ? result.active : []);
const skippedArr = Array.isArray(result?.skipped) ? result.skipped : [];
skipped = new Map(skippedArr.map((item) => [item.url, item]));
notify();
} catch (err) {
console.warn('[broadcast-relays] loadBroadcastRelays failed:', err?.message || err);
}
}
// ── Read ─────────────────────────────────────────────────────────────────────
/** Return an array of active broadcast relay URLs. */
export function getActiveBroadcastRelays() {
return Array.from(active);
}
/** Return an array of { url, reason, timestamp } for skip-marked relays. */
export function getSkippedBroadcastRelays() {
return Array.from(skipped.values());
}
/** Check whether a URL is in the active broadcast set. */
export function isBroadcastRelay(url) {
if (!url) return false;
return active.has(url);
}
// ── Mutate ───────────────────────────────────────────────────────────────────
/**
* Add a relay URL to the active broadcast set and persist via the worker.
* The URL is normalized before being stored.
*/
export async function addBroadcastRelay(url) {
const normalized = normalizeRelayUrl(url);
if (!normalized) {
console.warn('[broadcast-relays] addBroadcastRelay: invalid url', url);
return;
}
if (active.has(normalized)) return;
active.add(normalized);
try {
await saveBroadcastRelays(Array.from(active));
} catch (err) {
console.error('[broadcast-relays] addBroadcastRelay save failed:', err?.message || err);
}
notify();
}
/**
* Remove a relay URL from the active broadcast set and persist via the worker.
*/
export async function removeBroadcastRelay(url) {
if (!active.has(url)) return;
active.delete(url);
try {
await saveBroadcastRelays(Array.from(active));
} catch (err) {
console.error('[broadcast-relays] removeBroadcastRelay save failed:', err?.message || err);
}
notify();
}
/**
* One-click seed: add all discovered relays (from follows' kind 10002 lists)
* to the broadcast set. Returns the number of newly added relays.
*/
export async function seedFromDiscovered() {
try {
const result = await seedBroadcastRelays();
await loadBroadcastRelays(); // refresh from worker
return result?.added || 0;
} catch (err) {
console.error('[broadcast-relays] seedFromDiscovered failed:', err?.message || err);
return 0;
}
}
/**
* Remove a relay from the skip-mark set so it is included in future publishes.
*/
export async function unskipRelay(url) {
if (!url) return;
try {
await unskipBroadcastRelay(url);
await loadBroadcastRelays();
} catch (err) {
console.error('[broadcast-relays] unskipRelay failed:', err?.message || err);
}
}
// ── Events ───────────────────────────────────────────────────────────────────
/**
* Register a callback that fires whenever the active/skipped sets change.
* @param {Function} callback
* @returns {Function} unsubscribe
*/
export function onBroadcastRelaysChanged(callback) {
if (typeof callback !== 'function') return () => {};
listeners.add(callback);
return () => listeners.delete(callback);
}
function notify() {
const snapshot = {
active: Array.from(active),
skipped: Array.from(skipped.values()),
};
for (const cb of listeners) {
try {
cb(snapshot);
} catch (err) {
console.warn('[broadcast-relays] Listener error:', err);
}
}
}
console.log('[broadcast-relays] Module loaded');

View File

@@ -373,6 +373,30 @@ function handleWorkerMessage(event) {
window.dispatchEvent(new CustomEvent('ndkRelays', { detail: message.data }));
} else if (message.type === 'muteListUpdated') {
window.dispatchEvent(new CustomEvent('ndkMuteListUpdated', { detail: message.data || {} }));
} else if (message.type === 'broadcastRelaysUpdated') {
// Broadcast relay list (kind 10088) changed — either from a save in
// this tab, a live subscription update, or a cross-tab edit. Pages
// (e.g. relays.html sidenav) re-render on this event.
window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated'));
} else if (message.type === 'broadcastProgress') {
// Live per-relay publish progress from handlePublish. Pass through the
// full payload so footers can show "📡 42/150 relays · relay.example.com".
window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {
detail: {
sessionId: message.sessionId,
phase: message.phase,
eventKind: message.eventKind,
eventId: message.eventId,
total: message.total,
successful: message.successful,
failed: message.failed,
latestRelay: message.latestRelay,
latestError: message.latestError,
relayUrls: message.relayUrls || [],
batchCurrent: message.batchCurrent,
batchTotal: message.batchTotal,
}
}));
} else if (message.type === 'startupStatus') {
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
} else if (message.type === 'relayEvent') {
@@ -725,6 +749,52 @@ export function sendNip17Message(recipientPubkey, content) {
);
}
// ---------------------------------------------------------------------------
// Broadcast relays (kind 10088) — thin wrappers over sendWorkerRequest.
// The worker owns the kind 10088 state (active r-tags + skip-marked x-tags)
// and unions the active set into every public publish automatically.
// ---------------------------------------------------------------------------
/** Returns { active: string[], skipped: { url, reason, timestamp }[] } */
export function getBroadcastRelays() {
return sendWorkerRequest(
'getBroadcastRelays',
{},
15000,
'getBroadcastRelays timeout'
);
}
/** Save a new active broadcast relay list (unioned with existing r-tags). */
export function saveBroadcastRelays(urls) {
return sendWorkerRequest(
'saveBroadcastRelays',
{ urls: Array.isArray(urls) ? urls : [] },
30000,
'saveBroadcastRelays timeout'
);
}
/** One-click seed: add all discovered relays to the broadcast list. Returns { added } */
export function seedBroadcastRelays() {
return sendWorkerRequest(
'seedBroadcastRelays',
{},
30000,
'seedBroadcastRelays timeout'
);
}
/** Remove a relay from the skip-mark set so it is included in future publishes. */
export function unskipBroadcastRelay(url) {
return sendWorkerRequest(
'unskipBroadcastRelay',
{ url: String(url || '') },
30000,
'unskipBroadcastRelay timeout'
);
}
/**
* Fetch events from a single relay
* Returns events from that specific relay

View File

@@ -915,16 +915,78 @@ export function formatTimeAgo(timestamp) {
}
// Store timestamps for real-time updates
const timeAgoElements = new Map(); // element -> timestamp
const timeAgoElements = new Map(); // element -> { timestamp, eventId }
// Track relay publish info per event ID from broadcastProgress events.
// Keyed by event ID → { count, urls } where urls is an array of relay URLs.
const relayInfoByEventId = new Map();
// Listen for broadcast progress events to capture final relay counts and URLs.
// The worker emits ndkBroadcastProgress with phase 'done' containing the
// total successful count and the list of relay URLs. We store it so post
// cards can show "21r - 1h" with a hover tooltip listing the relays.
if (typeof window !== 'undefined') {
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || d.phase !== 'done') return;
const eventId = d.eventId;
if (!eventId) return;
relayInfoByEventId.set(eventId, {
count: d.successful || 0,
urls: Array.isArray(d.relayUrls) ? d.relayUrls : [],
});
// Update any already-rendered time elements for this event.
timeAgoElements.forEach((info, element) => {
if (info.eventId === eventId && element.isConnected) {
updateTimeAgoElement(element, info);
}
});
});
}
/**
* Build the hover tooltip text for a relay list.
* Shows "Published to N relays:" followed by the URL list, truncated.
* @param {number} count - Number of successful relays
* @param {string[]} urls - Array of relay URLs
* @returns {string}
*/
function buildRelayTooltip(count, urls) {
if (!count || count === 0) return '';
const header = `Published to ${count} relay${count === 1 ? '' : 's'}:`;
if (!urls || urls.length === 0) return header;
// Show up to 50 relay URLs in the tooltip; beyond that, show a summary.
if (urls.length <= 50) {
return header + '\n' + urls.join('\n');
}
return header + '\n' + urls.slice(0, 50).join('\n') + `\n… and ${urls.length - 50} more`;
}
/**
* Update a time-ago element's text and tooltip from its stored info.
* @param {HTMLElement} element - The element to update
* @param {{timestamp: number, eventId: string}} info - Stored info
*/
function updateTimeAgoElement(element, info) {
const timeStr = formatTimeAgo(info.timestamp);
if (info.eventId && relayInfoByEventId.has(info.eventId)) {
const relayInfo = relayInfoByEventId.get(info.eventId);
element.textContent = `${relayInfo.count}r - ${timeStr}`;
element.title = buildRelayTooltip(relayInfo.count, relayInfo.urls);
} else {
element.textContent = timeStr;
}
}
/**
* Register an element for time updates
* @param {HTMLElement} element - The element to update
* @param {number} timestamp - Unix timestamp in seconds
* @param {string} [eventId] - Optional event ID for relay count display
*/
export function registerTimeAgo(element, timestamp) {
timeAgoElements.set(element, timestamp);
element.textContent = formatTimeAgo(timestamp);
export function registerTimeAgo(element, timestamp, eventId) {
timeAgoElements.set(element, { timestamp, eventId });
updateTimeAgoElement(element, { timestamp, eventId });
}
/**
@@ -932,9 +994,9 @@ export function registerTimeAgo(element, timestamp) {
* Call this periodically (e.g., every 30 seconds)
*/
export function updateTimeAgos() {
timeAgoElements.forEach((timestamp, element) => {
timeAgoElements.forEach((info, element) => {
if (element.isConnected) {
element.textContent = formatTimeAgo(timestamp);
updateTimeAgoElement(element, info);
} else {
// Clean up detached elements
timeAgoElements.delete(element);
@@ -1085,10 +1147,12 @@ export function renderFooterRow(eventId, eventData, options = {}) {
container.appendChild(nutzapItem);
container.appendChild(midControls);
// Time as the final item in the same row
// Time as the final item in the same row.
// Pass eventId so registerTimeAgo can prepend the relay count
// (e.g., "21r - 1h") when a broadcastProgress 'done' event arrives.
const timeEl = document.createElement('span');
timeEl.className = 'divPostTime';
registerTimeAgo(timeEl, eventData.created_at);
registerTimeAgo(timeEl, eventData.created_at, eventId);
container.appendChild(timeEl);
footerRow.appendChild(container);

345
www/js/stego.mjs Normal file
View 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)));
}

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.56",
"VERSION_NUMBER": "0.7.56",
"BUILD_DATE": "2026-06-27T12:17:42.524Z"
"VERSION": "v0.7.88",
"VERSION_NUMBER": "0.7.88",
"BUILD_DATE": "2026-07-01T19:56:16.258Z"
}

1872
www/llm-steganography.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -319,6 +319,16 @@ let relayActivityStats = new Map(); // relay.url -> { readCount, writeCount, las
let relaysWithListeners = new Set(); // Track which relays have event listeners attached
let relayTypes = new Map(); // relay.url -> 'read'|'write'|'both' (from kind 10002)
let latestRelayListCreatedAt = 0; // guard against stale kind 10002 events arriving out of order
// Broadcast relay state (kind 10088 — NIP-51 private list, NIP-44 encrypted content).
// Mirrors Amethyst's BroadcastRelayListEvent. Active relays are unioned into every
// public publish via handlePublish; skip-marked relays are kept in the list for
// visibility but excluded from the publish relay set.
let broadcastRelayUrls = new Set(); // active "r" tags from kind 10088
let skippedRelayUrls = new Map(); // url -> { reason, timestamp } ("x" tags)
let latest10088Event = null; // raw event for update-preserving saves
let skipMarkSaveTimer = null; // debounce timer for skip-mark saves
let latest10088CreatedAt = 0; // guard against stale kind 10088 events
let relayEventsPruneTimer = null;
let relayConnectAttemptStartedAt = new Map(); // relay.url -> timestamp ms
let relayLastDisconnectMeta = new Map(); // relay.url -> { timestamp, code, reason, wasClean }
@@ -1756,6 +1766,273 @@ async function loadWorkerMuteList(pubkey, { reason = 'manual' } = {}) {
}
}
// ---------------------------------------------------------------------------
// Broadcast relays (kind 10088) — NIP-51 private list, NIP-44 encrypted content.
// Active relays ("r" tags) are unioned into every public publish; skip-marked
// relays ("x" tags) are kept in the list for visibility but excluded from the
// publish relay set. Coexists with Amethyst, which only reads/writes "r" tags.
// ---------------------------------------------------------------------------
/** Kinds that should be broadcast to the user's broadcast relay set. */
const BROADCAST_KINDS = new Set([1, 6, 7, 30023, 30024, 30315, 31123, 31124, 10088]);
/**
* Returns true if the given event kind should be unioned with the broadcast
* relay set on publish. Mirrors Amethyst's Account.kt:1217 gate — public
* posts/reactions/reposts get the broadcast set; DMs, app settings, wallet
* events, and gift-wrapped events do NOT.
*/
function wantsBroadcastRelays(event) {
if (!event) return false;
return BROADCAST_KINDS.has(Number(event.kind));
}
/**
* Returns the active broadcast relay URLs (r-tags minus x-tag skip-marks).
* This is the set that handlePublish unions into the outbox write relays.
*/
function getActiveBroadcastUrls() {
return Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u));
}
/**
* Parse a kind 10088 event into broadcastRelayUrls / skippedRelayUrls.
* Public ["r", url] tags are read first, then the NIP-44-encrypted content is
* decrypted (reusing the same messageSigner plumbing as the mute list) and the
* private tags are merged: ["r", url] → broadcastRelayUrls,
* ["x", url, reason, timestamp] → skippedRelayUrls.
*
* @param {object} event - raw kind 10088 event (NDKEvent or plain object)
* @param {string} author - hex pubkey of the author (for NIP-44 decrypt)
* @returns {Promise<boolean>} true if decryption succeeded (or was unnecessary)
*/
async function parse10088Event(event, author) {
if (!event) {
broadcastRelayUrls = new Set();
skippedRelayUrls = new Map();
latest10088Event = null;
latest10088CreatedAt = 0;
return true;
}
const nextR = new Set();
const nextX = new Map();
// Public r-tags (rare for kind 10088, but supported by the NIP).
for (const tag of (event.tags || [])) {
if (tag[0] === 'r' && tag[1]) {
nextR.add(tag[1]);
} else if (tag[0] === 'x' && tag[1]) {
nextX.set(tag[1], {
reason: tag[2] || 'unknown',
timestamp: Number(tag[3]) || 0,
});
}
}
// Private tags live in the NIP-44-encrypted content blob.
const encrypted = String(event.content || '').trim();
if (encrypted && messageSigner) {
try {
const decryptMethod = isWorkerNip04Content(encrypted) ? 'decrypt' : 'nip44Decrypt';
const decryptResult = await messageSigner.requestFromPage(decryptMethod, {
senderPubkey: author,
ciphertext: encrypted,
});
const plaintext = typeof decryptResult === 'string'
? decryptResult
: String(decryptResult?.plaintext || '');
if (plaintext) {
const privateTags = JSON.parse(plaintext);
if (Array.isArray(privateTags)) {
for (const tag of privateTags) {
if (!Array.isArray(tag)) continue;
if (tag[0] === 'r' && tag[1]) {
nextR.add(tag[1]);
} else if (tag[0] === 'x' && tag[1]) {
nextX.set(tag[1], {
reason: tag[2] || 'unknown',
timestamp: Number(tag[3]) || 0,
});
}
}
}
}
} catch (error) {
console.warn('[Worker][broadcast] Failed decrypting kind 10088 content:', error?.message || error);
return false;
}
}
broadcastRelayUrls = nextR;
skippedRelayUrls = nextX;
latest10088Event = event;
latest10088CreatedAt = Number(event.created_at || 0);
return true;
}
/**
* Hydrate broadcast relay state from the user's latest kind 10088 event.
* Called once during first init (after fetchUserRelays). Leaves both sets
* empty if no event exists or decryption fails.
*/
async function loadBroadcastRelays(pubkey) {
const author = normalizeHexPubkey(pubkey || currentPubkey);
if (!author || !ndk) {
return { loaded: false, reason: 'missing-author-or-ndk' };
}
try {
const filter = { kinds: [10088], authors: [author], limit: 1 };
const events = await ndk.fetchEvents(filter);
const latest = events && events.size > 0
? Array.from(events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
: null;
if (!latest) {
broadcastRelayUrls = new Set();
skippedRelayUrls = new Map();
latest10088Event = null;
latest10088CreatedAt = 0;
console.log('[Worker][broadcast] No kind 10088 event found, broadcast relays empty');
return { loaded: true, active: 0, skipped: 0 };
}
await parse10088Event(latest, author);
console.log('[Worker][broadcast] Loaded kind 10088', {
eventId: latest.id,
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
});
return {
loaded: true,
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
};
} catch (error) {
console.warn('[Worker][broadcast] Failed to load kind 10088:', error?.message || error);
return { loaded: false, error: error?.message || String(error) };
}
}
/**
* Build, sign, and publish a kind 10088 event preserving existing r-tags
* (union with `activeUrls`) and all existing x-tags (skip-marks). The private
* tags (r + x) are NIP-44-encrypted as the content blob; public tags are left
* empty so Amethyst's `create()` pattern is mirrored.
*
* @param {string[]} activeUrls - the new active r-tag list (unioned with existing)
* @returns {Promise<boolean>} true if published successfully
*/
async function saveBroadcastRelaysInternal(activeUrls) {
if (!ndk || !currentPubkey) {
console.warn('[Worker][broadcast] Cannot save kind 10088 — ndk/pubkey missing');
return false;
}
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
if (!NDKEvent) {
console.warn('[Worker][broadcast] NDKEvent unavailable, cannot save kind 10088');
return false;
}
// Preserve existing r-tags from the latest event (so we don't clobber
// Amethyst's list) and union with the new active list.
const existingRTags = latest10088Event
? (latest10088Event.tags || []).filter(t => t[0] === 'r' && t[1])
: [];
const mergedRUrls = new Set([
...existingRTags.map(t => t[1]),
...(Array.isArray(activeUrls) ? activeUrls : []),
]);
// Preserve existing x-tags (skip-marks) — never touch them on a save.
const existingXTags = latest10088Event
? (latest10088Event.tags || []).filter(t => t[0] === 'x' && t[1])
: [];
// Also include any in-memory skip-marks not yet persisted.
const mergedXEntries = new Map();
for (const tag of existingXTags) {
mergedXEntries.set(tag[1], [tag[2] || 'unknown', Number(tag[3]) || 0]);
}
for (const [url, info] of skippedRelayUrls.entries()) {
mergedXEntries.set(url, [info.reason || 'unknown', Number(info.timestamp) || 0]);
}
const privateTags = [
...Array.from(mergedRUrls).map(u => ['r', u]),
...Array.from(mergedXEntries.entries()).map(([url, [reason, ts]]) => ['x', url, reason, ts]),
];
// NIP-44-encrypt the private tags as the content blob.
let encrypted = '';
try {
const encryptedResp = await messageSigner.requestFromPage('nip44Encrypt', {
recipientPubkey: currentPubkey,
plaintext: JSON.stringify(privateTags),
});
encrypted = typeof encryptedResp === 'string' ? encryptedResp : encryptedResp?.ciphertext;
} catch (error) {
console.warn('[Worker][broadcast] NIP-44 encrypt failed, cannot save kind 10088:', error?.message || error);
return false;
}
if (!encrypted) {
console.warn('[Worker][broadcast] Empty ciphertext from nip44Encrypt, aborting save');
return false;
}
const event = new NDKEvent(ndk, {
kind: 10088,
content: encrypted,
tags: [], // public tags empty — keep private like Amethyst's create()
created_at: Math.floor(Date.now() / 1000),
});
try {
await signEventWithMessageSigner(event);
await event.publish();
} catch (error) {
console.warn('[Worker][broadcast] Failed to sign/publish kind 10088:', error?.message || error);
return false;
}
// Update local state from the freshly published event.
latest10088Event = event;
latest10088CreatedAt = event.created_at || Math.floor(Date.now() / 1000);
broadcastRelayUrls = mergedRUrls;
// Refresh skippedRelayUrls from the merged set so newly-added skip-marks persist.
const nextSkipped = new Map();
for (const [url, [reason, ts]] of mergedXEntries.entries()) {
nextSkipped.set(url, { reason, timestamp: ts });
}
skippedRelayUrls = nextSkipped;
broadcast({ type: 'broadcastRelaysUpdated' });
console.log('[Worker][broadcast] Saved kind 10088', {
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
});
return true;
}
/**
* Debounced (5s) persistence of skip-marks to kind 10088. Called by
* handlePublish after failure marking. Multiple failures in one publish (or
* several publishes close together) produce a single republish.
*/
function scheduleSkipMarkSave() {
if (skipMarkSaveTimer) clearTimeout(skipMarkSaveTimer);
skipMarkSaveTimer = setTimeout(async () => {
skipMarkSaveTimer = null;
try {
// Preserve current r-tags; only the x-tags change.
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
} catch (error) {
console.warn('[Worker][broadcast] Debounced skip-mark save failed:', error?.message || error);
}
}, 5000);
}
function applyLightwalletDeletionEvent(event) {
let changed = false;
for (const tag of event?.tags || []) {
@@ -4291,6 +4568,16 @@ async function handleWalletCreateDeposit(requestId, amount, mint, port) {
}
async function handleWalletPayInvoice(requestId, invoice, mint, port) {
const startedAt = Date.now();
const traceId = `walletPayInvoice:${requestId}`;
const log = (phase, extra = null) => {
if (extra === null) {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
} else {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
}
};
try {
await ensureDirectWalletLoaded();
@@ -4303,6 +4590,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
log('mint-selection', {
requestedMint: requestedMint || null,
orderedMints: orderedMints.map((m) => ({ mint: m, proofBalance: getProofTotal(directProofStore[m]) }))
});
let payResult = null;
let usedMint = null;
let paidAmountSats = 0;
@@ -4311,7 +4603,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
for (const mintUrl of orderedMints) {
try {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
const proofBalance = getProofTotal(proofs);
log('mint attempt:start', { mintUrl, proofBalance, proofCount: proofs.length });
if (proofs.length === 0) {
log('mint attempt:no-proofs', { mintUrl });
attemptErrors.push({ mint: mintUrl, message: 'No proofs available for mint' });
continue;
}
@@ -4320,44 +4616,60 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
const meltQuote = await wallet.createMeltQuote(pr);
const quoteAmount = Number(meltQuote?.amount || 0);
const quoteFeeReserve = Number(meltQuote?.fee_reserve || 0);
// The mint's quoted fee_reserve is an estimate; the actual melt fee can be
// slightly higher, causing "not enough inputs provided for melt" errors.
// Add a safety buffer — any overpayment is returned as change proofs.
const FEE_RESERVE_BUFFER_SATS = 10;
const required = quoteAmount + quoteFeeReserve;
const sendAmount = required + FEE_RESERVE_BUFFER_SATS;
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required, sendAmount });
if (!Number.isFinite(required) || required <= 0) {
log('mint attempt:invalid-quote', { mintUrl, required });
attemptErrors.push({ mint: mintUrl, message: 'Invalid melt quote amount' });
continue;
}
const proofBalance = getProofTotal(proofs);
if (proofBalance < required) {
if (proofBalance < sendAmount) {
log('mint attempt:insufficient', { mintUrl, proofBalance, sendAmount });
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
continue;
}
const sendResult = await wallet.send(required, proofs);
const sendResult = await wallet.send(sendAmount, proofs);
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length, sendAmount });
const meltResult = await wallet.meltProofs(meltQuote, sendProofs);
const change = Array.isArray(meltResult?.change) ? meltResult.change : [];
upsertMintProofs(mintUrl, [...keep, ...change]);
const changeAmount = getProofTotal(change);
const spentAmount = Math.max(0, required - changeAmount);
const spentAmount = Math.max(0, sendAmount - changeAmount);
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
? quoteAmount
: spentAmount;
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, spentAmount, preimage: meltResult?.preimage || null });
usedMint = mintUrl;
payResult = meltResult;
break;
} catch (error) {
log('mint attempt:error', { mintUrl, message: error?.message || String(error) });
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
}
}
if (!payResult || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
log('all-mints-failed', { attemptErrors });
throw new Error(`Failed to pay invoice. ${detail || 'No mint could complete melt'}`);
}
log('paid', { usedMint, paidAmountSats, preimage: payResult?.preimage || null });
const tx = appendDirectTransaction({
type: 'pay',
amount: Number.isFinite(paidAmountSats) && paidAmountSats > 0
@@ -4403,6 +4715,7 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
}
})();
} catch (error) {
log('error', { message: error?.message || String(error), stack: error?.stack || null });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
@@ -5181,6 +5494,44 @@ async function handleInit(pubkey, port) {
});
});
// Hydrate broadcast relay state (kind 10088) — active r-tags +
// skip-marked x-tags. Non-fatal: leaves both sets empty on failure.
broadcastStartupStatus('Fetching broadcast relay list (kind 10088)...');
try {
await loadBroadcastRelays(pubkey);
} catch (err) {
console.warn('[Worker] Broadcast relay hydration failed, continuing init:', err?.message || err);
}
// Subscribe to kind 10088 for live updates from other tabs/clients
// (including Amethyst). Re-parses into broadcastRelayUrls /
// skippedRelayUrls and broadcasts broadcastRelaysUpdated to all ports.
try {
console.log('[Worker] Subscribing to kind 10088 broadcast relay updates for:', pubkey);
const broadcastFilter = { kinds: [10088], authors: [pubkey] };
const broadcastSub = ndk.subscribe(broadcastFilter, { closeOnEose: false });
broadcastSub.on('event', async (event) => {
const incomingCreatedAt = event?.created_at || 0;
if (incomingCreatedAt < latest10088CreatedAt) {
console.log('[Worker] Ignoring stale kind 10088 event:', {
incomingCreatedAt,
latest10088CreatedAt,
});
return;
}
const author = normalizeHexPubkey(pubkey || currentPubkey);
const ok = await parse10088Event(event, author);
if (!ok) return;
console.log('[Worker] Updated broadcast relays from subscription:', {
active: broadcastRelayUrls.size,
skipped: skippedRelayUrls.size,
});
broadcast({ type: 'broadcastRelaysUpdated' });
});
} catch (subErr) {
console.warn('[Worker] Kind 10088 subscription failed:', subErr?.message || subErr);
}
// Phase 1 startup downloads for wallet-related events + follows.
// Run in background so init does not block UI startup paths (e.g. walletInit).
scheduleStartupDownloads(pubkey, 'first-init');
@@ -5833,35 +6184,363 @@ async function handlePublish(requestId, event, port) {
console.log('[Worker] Requesting signature from page...');
await signEventWithMessageSigner(ndkEvent);
console.log('[Worker] Event signed, publishing to relays...');
// Publish to relays
const relaySet = await ndkEvent.publish();
// --- Broadcast relay union -------------------------------------------------
// If this event kind is broadcast-worthy and the user has active broadcast
// relays (kind 10088 r-tags minus x-tag skip-marks), union them with the
// user's outbox write relays and publish to the combined set via a
// temporary NDKRelaySet. NDK's fromRelayUrls uses pool.useTemporaryRelay
// so hundreds of broadcast relays connect transiently without polluting
// the read subscription pool.
const activeBroadcastUrls = getActiveBroadcastUrls();
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
let outboxWriteUrls = [];
if (isBroadcast) {
outboxWriteUrls = Array.from(relayTypes.entries())
.filter(([, t]) => t === 'write' || t === 'both')
.map(([url]) => url);
}
const allBroadcastUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
// --- Live broadcast progress streaming ------------------------------------
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per
// relay as each completes. We attach listeners BEFORE publish() and stream
// progress to all pages via broadcast() so footers can show the count.
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set(); // explicit rejections (auth-required, blocked, etc.)
const timedOutSoFar = new Set(); // timeouts
const totalTarget = isBroadcast ? allBroadcastUrls.length : 0;
// Track current batch for progress display. Updated by the batch loop.
let currentBatch = 0;
let totalBatches = 0;
// Debug: track publish start time for timing analysis (defined in outer
// scope so the batch loop and silent-failure detection can access it).
const publishStartTime = Date.now();
if (isBroadcast && totalTarget > 0) {
console.log(`[Worker] Broadcasting to ${allBroadcastUrls.length} relays ` +
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
`${skippedRelayUrls.size} skipped)`);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventKind: event.kind,
eventId: ndkEvent.id || null,
phase: 'start',
total: totalTarget,
successful: 0,
failed: 0,
latestRelay: null,
});
ndkEvent.on('relay:published', (relay) => {
const url = relay?.url || String(relay || '');
if (!url) return;
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
publishedSoFar.add(url);
trackRelayWrite(url);
console.log(`[Worker][broadcast] ✅ ${url} published (${elapsed}s) — batch ${currentBatch}/${totalBatches}, ${publishedSoFar.size}/${totalTarget} total`);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: url,
batchCurrent: currentBatch,
batchTotal: totalBatches,
});
});
ndkEvent.on('relay:publish:failed', (relay, err) => {
const url = relay?.url || String(relay || '');
if (!url) return;
const errMsg = err?.message || String(err) || '';
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
const isTimeout = /timeout/i.test(errMsg);
if (isTimeout) {
timedOutSoFar.add(url);
console.log(`[Worker][broadcast] ⏱️ ${url} TIMEOUT (${elapsed}s) — batch ${currentBatch}/${totalBatches}`);
} else {
failedSoFar.add(url);
console.log(`[Worker][broadcast] ❌ ${url} REJECTED (${elapsed}s): ${errMsg} — batch ${currentBatch}/${totalBatches}`);
}
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: url,
latestError: errMsg,
batchCurrent: currentBatch,
batchTotal: totalBatches,
});
});
}
// --- Batched publishing for broadcasts ------------------------------------
// Browsers can only handle ~50-100 simultaneous WebSocket connections.
// Publishing to 600+ relays in one shot causes most connections to queue
// and time out before getting a slot. We split the relay URLs into batches
// of 25 and publish to each batch sequentially, keeping the number of
// simultaneous connections manageable.
const BATCH_SIZE = 25;
const PER_RELAY_TIMEOUT_MS = 10000; // 10s per relay within a batch
let relaySet = null;
if (isBroadcast && totalTarget > 0 && NDKRelaySet?.fromRelayUrls) {
// --- Phase 1: Publish to the user's standard outbox relays first ---
// This ensures the post lands on the user's primary relays quickly,
// and lets us send the response back to the page so the composer
// clears immediately. The broadcast relay batches run afterward.
if (outboxWriteUrls.length > 0) {
console.log(`[Worker] Phase 1: Publishing to ${outboxWriteUrls.length} outbox relays first`);
try {
const outboxSet = NDKRelaySet.fromRelayUrls(outboxWriteUrls, ndk);
await ndkEvent.publish(outboxSet, 10000, 1);
} catch (outboxErr) {
console.warn('[Worker] Outbox publish error (expected if some relays fail):', outboxErr?.message || outboxErr);
}
console.log(`[Worker] Phase 1 complete: ${publishedSoFar.size} outbox relays succeeded`);
}
// --- Send response back to the page NOW so the composer clears ---
// The post is already on the user's outbox relays. The broadcast
// batches continue in the background.
const earlyRelayResults = {
successful: Array.from(publishedSoFar),
failed: [],
};
port.postMessage({
type: 'response',
requestId: requestId,
data: {
success: true,
relayResults: earlyRelayResults,
totalRelays: earlyRelayResults.successful.length,
}
});
console.log('[Worker] Sent early publish response — composer can clear now');
// --- Phase 2: Broadcast to remaining relays in batches (background) ---
// Only publish to relays that aren't already in the outbox set
// (those were already published in Phase 1).
const broadcastOnlyUrls = activeBroadcastUrls.filter(u => !outboxWriteUrls.includes(u));
if (broadcastOnlyUrls.length > 0) {
const batches = [];
for (let i = 0; i < broadcastOnlyUrls.length; i += BATCH_SIZE) {
batches.push(broadcastOnlyUrls.slice(i, i + BATCH_SIZE));
}
totalBatches = batches.length;
console.log(`[Worker] Phase 2: Broadcasting to ${broadcastOnlyUrls.length} relays in ${totalBatches} batches of ${BATCH_SIZE}`);
// Emit a progress event showing batch info before starting batches.
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: null,
batchCurrent: 0,
batchTotal: totalBatches,
});
for (let bi = 0; bi < batches.length; bi++) {
currentBatch = bi + 1; // update before publish so per-relay events include it
const batch = batches[bi];
const batchStartTime = Date.now();
console.log(`[Worker][broadcast] Batch ${bi + 1}/${totalBatches} starting — ${batch.length} relays: ${batch.slice(0, 5).join(', ')}${batch.length > 5 ? '...' : ''}`);
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
if (batchSet?.relays) {
for (const relay of batchSet.relays) {
try {
if (relay.connectionTimeout !== undefined) {
relay.connectionTimeout = PER_RELAY_TIMEOUT_MS;
}
} catch (_) { /* relay may be read-only */ }
}
}
try {
await ndkEvent.publish(batchSet, PER_RELAY_TIMEOUT_MS, 1);
} catch (batchErr) {
// Expected — some relays in this batch may fail.
}
// --- Detect silent failures ---
// NDK's relay.publish() can resolve false without emitting
// relay:publish:failed (e.g., DNS errors, connection refused,
// HTTP handshake failures). These relays are not in any of
// our tracked sets. Mark them as timed out so they get
// skip-marked and don't waste time on the next publish.
for (const url of batch) {
if (!publishedSoFar.has(url) && !failedSoFar.has(url) && !timedOutSoFar.has(url)) {
timedOutSoFar.add(url);
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
console.log(`[Worker][broadcast] ⏱️ ${url} SILENT TIMEOUT (${elapsed}s) — batch ${currentBatch}/${totalBatches} (no response, likely connection failure)`);
}
}
// Emit a progress event with batch info after each batch.
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: null,
batchCurrent: bi + 1,
batchTotal: totalBatches,
});
const batchElapsed = ((Date.now() - batchStartTime) / 1000).toFixed(1);
console.log(`[Worker] Batch ${bi + 1}/${totalBatches} complete (${batchElapsed}s) — ` +
`${publishedSoFar.size}/${totalTarget} total succeeded, ` +
`${failedSoFar.size + timedOutSoFar.size} failed/timeout so far`);
}
}
// Final broadcast progress message with complete results.
const totalFailed = failedSoFar.size + timedOutSoFar.size;
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: totalTarget,
successful: publishedSoFar.size,
failed: totalFailed,
latestRelay: null,
relayUrls: Array.from(publishedSoFar),
});
try {
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
} catch (_cleanupErr) { /* ignore */ }
// --- Failure marking (background, after all batches) ---
// Skip-mark both explicit rejections AND timeouts. A relay that
// times out is either down, too slow, or unreachable — skip it
// next time to avoid wasting time on it.
const allFailed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
const newSkips = allFailed
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
const isTimeout = timedOutSoFar.has(url);
skippedRelayUrls.set(url, {
reason: isTimeout ? 'publish-timeout' : 'publish-rejected',
timestamp: Math.floor(Date.now() / 1000),
});
}
scheduleSkipMarkSave();
const timeoutCount = newSkips.filter(u => timedOutSoFar.has(u)).length;
const rejectCount = newSkips.length - timeoutCount;
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays ` +
`(${rejectCount} rejected, ${timeoutCount} timed out)`);
}
// Return early — we already sent the response above. The rest of
// handlePublish (relayResults, kind 10002 sync, etc.) is skipped for
// broadcasts since we've handled everything here.
return;
} else {
// Normal (non-broadcast) publish — await the full result as before.
try {
relaySet = await ndkEvent.publish(null, undefined, undefined);
} catch (publishError) {
console.warn('[Worker] publish() threw:', publishError?.message || publishError);
relaySet = null;
}
}
// For non-broadcast publishes, emit a simplified broadcastProgress 'done'
// event so post cards can still show the relay count (e.g., "3r - 1h").
// We use the relaySet result to count successful relays.
if (!isBroadcast) {
const successCount = relaySet ? relaySet.size : 0;
const successUrls = relaySet
? Array.from(relaySet).map(r => r?.url || '').filter(Boolean)
: [];
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: successCount,
successful: successCount,
failed: 0,
latestRelay: null,
relayUrls: successUrls,
});
}
// Get detailed relay results
const relayResults = {
successful: [],
failed: []
};
// Check which relays are in the relay set (successful)
if (relaySet && relaySet.size > 0) {
for (const relay of relaySet) {
relayResults.successful.push(relay.url);
// Track relay write activity
trackRelayWrite(relay.url);
// Track relay write activity (also tracked live above for broadcasts)
if (!isBroadcast) trackRelayWrite(relay.url);
}
}
// Check which connected relays didn't accept it
for (const relay of ndk.pool.relays.values()) {
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
relayResults.failed.push(relay.url);
// For broadcast publishes, use the live-tracked sets from the
// relay:published / relay:publish:failed events as the source of truth
// (they cover temporary relays that may not be in ndk.pool). For normal
// publishes, fall back to the pool-scan logic.
if (isBroadcast) {
relayResults.successful = Array.from(publishedSoFar);
// Include both explicit rejections and timeouts in the failed list
// for display purposes, but keep them distinguishable for skip-marking.
relayResults.failed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
} else {
// Check which connected relays didn't accept it
for (const relay of ndk.pool.relays.values()) {
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
relayResults.failed.push(relay.url);
}
}
}
console.log('[Worker] ✅ Published to relays:', relayResults.successful);
console.log('[Worker] ❌ Failed relays:', relayResults.failed);
console.log('[Worker] ✅ Published to relays:', relayResults.successful.length, relayResults.successful.slice(0, 10), relayResults.successful.length > 10 ? `... +${relayResults.successful.length - 10} more` : '');
console.log('[Worker] ❌ Failed relays:', relayResults.failed.length);
// --- Failure marking for broadcast relays ---------------------------------
// Skip-mark both explicit rejections AND timeouts. A relay that times
// out is either down, too slow, or unreachable — skip it next time.
if (isBroadcast) {
const allFailed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
const newSkips = allFailed
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
const isTimeout = timedOutSoFar.has(url);
skippedRelayUrls.set(url, {
reason: isTimeout ? 'publish-timeout' : 'publish-rejected',
timestamp: Math.floor(Date.now() / 1000),
});
}
scheduleSkipMarkSave();
const timeoutCount2 = newSkips.filter(u => timedOutSoFar.has(u)).length;
const rejectCount2 = newSkips.length - timeoutCount2;
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays ` +
`(${rejectCount2} rejected, ${timeoutCount2} timed out)`);
}
}
// If this is a kind 10002 (relay list) event, update stored relay types
// AND sync the live relay pool so newly-added relays are actually connectable.
if (event.kind === 10002) {
@@ -7018,6 +7697,113 @@ self.onconnect = (event) => {
void handleGetDiscoveredRelays(requestId, port);
break;
case 'getBroadcastRelays':
port.postMessage({
type: 'response',
requestId,
data: {
active: Array.from(broadcastRelayUrls),
skipped: Array.from(skippedRelayUrls.entries()).map(
([url, info]) => ({ url, ...info })
),
},
});
break;
case 'saveBroadcastRelays': {
const urlsToSave = Array.isArray(e.data.urls) ? e.data.urls : [];
void saveBroadcastRelaysInternal(urlsToSave)
.then((ok) => {
port.postMessage({
type: 'response',
requestId,
data: { success: !!ok },
});
})
.catch((error) => {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error),
});
});
break;
}
case 'seedBroadcastRelays': {
// One-click "Add all discovered relays": union the discovered
// relays cache with the existing active r-tags, excluding any
// skip-marked relays, then persist.
try {
const discovered = Array.isArray(discoveredRelaysCache) ? discoveredRelaysCache : [];
const discoveredUrls = discovered
.map(r => r?.url)
.filter(Boolean);
const newUrls = discoveredUrls.filter(u =>
!broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
);
if (newUrls.length === 0) {
port.postMessage({ type: 'response', requestId, data: { added: 0 } });
break;
}
for (const u of newUrls) broadcastRelayUrls.add(u);
void saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls))
.then(() => {
port.postMessage({
type: 'response',
requestId,
data: { added: newUrls.length },
});
})
.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 'unskipBroadcastRelay': {
const unskipUrl = e.data.url;
if (unskipUrl) {
skippedRelayUrls.delete(unskipUrl);
// No need to add to broadcastRelayUrls — skipped relays stay
// in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls.
void saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls))
.then(() => {
broadcast({ type: 'broadcastRelaysUpdated' });
port.postMessage({
type: 'response',
requestId,
data: { success: true },
});
})
.catch((error) => {
port.postMessage({
type: 'response',
requestId,
error: error?.message || String(error),
});
});
} else {
port.postMessage({
type: 'response',
requestId,
error: 'Missing url for unskipBroadcastRelay',
});
}
break;
}
case 'setActiveSigningPort':
activeSigningPort = port;
console.log('[Worker] Active signing port set explicitly by page');

View File

@@ -173,6 +173,32 @@
color: var(--accent-color);
}
.notif-menu-item {
width: 100%;
text-align: left;
border: var(--button-border-width) solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 6px 8px;
cursor: pointer;
font: inherit;
font-size: 90%;
margin-bottom: 2px;
}
.notif-menu-item:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
.notif-menu-divider {
height: 1px;
background: var(--muted-color);
margin: 4px 0;
border: none;
}
.svgBtn {
fill: none;
stroke: var(--button-color);
@@ -377,7 +403,13 @@
publishEvent,
ensureMuteListLoaded,
isEventMuted,
addMute
addMute,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
@@ -385,7 +417,7 @@
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { createProfileCache } from './js/profile-cache.mjs';
import { formatTimeAgo } from './js/post-interactions.mjs';
import { formatTimeAgo, initInteractions } from './js/post-interactions.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
@@ -393,6 +425,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
const NOTIFICATION_KINDS = [1, 3, 4, 6, 7, 9735];
const NOTIFICATIONS_PAGE_SIZE = 40;
const MAX_STORED_NOTIFICATIONS = 500;
const MAX_POST_IMAGE_CACHE = 200;
const SVG_UNCHECKED = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><rect x="2" y="2" width="6" height="6" /></svg>`;
const SVG_CHECKED = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
const NOTIFICATION_FILTER_DEFS = [
@@ -422,6 +456,9 @@ import { createProfileCache } from './js/profile-cache.mjs';
const unreadNotificationsById = new Map();
const timeElements = new Map();
const postImageCache = new Map();
const targetEventCache = new Map();
let interactions = null;
let notifInteractionsSubId = null;
let visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
const profileCache = createProfileCache({
@@ -441,6 +478,40 @@ import { createProfileCache } from './js/profile-cache.mjs';
const divFooterRight = document.getElementById('divFooterRight');
const divNotificationFiltersList = document.getElementById('divNotificationFiltersList');
// Initialize the post-interactions module so notification dropdown menus
// can offer Like / Zap / Nutzap / Quote / Comment on the referenced post.
// The interaction bar itself is rendered hidden inside each notification
// row; menu items trigger clicks on its buttons.
interactions = initInteractions({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: ({ postId }) => {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
},
onMuteIntent: async ({ eventData }) => {
const targetPubkey = String(eventData?.pubkey || '').trim();
if (!targetPubkey || targetPubkey === currentPubkey) return;
const ok = window.confirm(`Block ${targetPubkey.slice(0, 8)}${targetPubkey.slice(-4)}?`);
if (!ok) return;
await blockActorPubkey(targetPubkey);
}
});
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
@@ -883,6 +954,251 @@ import { createProfileCache } from './js/profile-cache.mjs';
}
}
function buildNotificationRow(item) {
const row = document.createElement('div');
row.className = 'notif-item';
row.dataset.notifId = item.id;
row.dataset.notifCreatedAt = String(item.created_at || 0);
const avatar = document.createElement('img');
avatar.className = 'notif-avatar';
avatar.alt = '';
avatar.src = item.actor.picture || './favicon/favicon-dots2.ico';
avatar.onerror = () => {
console.warn('[notifications] actor avatar failed to load', {
eventId: item.id,
actor: item.actor.name,
src: avatar.src
});
avatar.onerror = null;
avatar.src = './favicon/favicon-dots2.ico';
};
if (item.actor?.pubkey) {
avatar.style.cursor = 'pointer';
avatar.title = 'Open profile';
avatar.addEventListener('click', (event) => {
event.stopPropagation();
const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`;
window.open(profileUrl, '_blank', 'noopener,noreferrer');
});
}
const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || '');
const secondaryEventUrl = makeEventPermalink(item.targetEventId);
let secondaryNode;
if (secondaryImageUrl) {
const secondaryAvatar = document.createElement('img');
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
secondaryAvatar.alt = '';
secondaryAvatar.src = secondaryImageUrl;
secondaryAvatar.onerror = () => {
console.debug('[notifications] secondary image failed to load', {
eventId: item.id,
src: secondaryAvatar.src
});
secondaryAvatar.style.visibility = 'hidden';
};
if (secondaryEventUrl) {
secondaryAvatar.style.cursor = 'pointer';
secondaryAvatar.title = 'Open related event';
secondaryAvatar.addEventListener('click', (event) => {
event.stopPropagation();
window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer');
});
}
secondaryNode = secondaryAvatar;
} else if (secondaryEventUrl) {
const secondaryPlaceholder = document.createElement('a');
secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder';
secondaryPlaceholder.href = secondaryEventUrl;
secondaryPlaceholder.target = '_blank';
secondaryPlaceholder.rel = 'noopener noreferrer';
secondaryPlaceholder.title = 'Open related event';
secondaryPlaceholder.setAttribute('aria-label', 'Open related event');
secondaryPlaceholder.addEventListener('click', (event) => {
event.stopPropagation();
});
secondaryNode = secondaryPlaceholder;
} else {
const secondaryAvatar = document.createElement('div');
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
secondaryAvatar.style.visibility = 'hidden';
secondaryNode = secondaryAvatar;
}
const content = document.createElement('div');
content.className = 'notif-content';
const name = document.createElement('div');
name.className = 'notif-name';
name.textContent = item.actor.name;
const desc = document.createElement('div');
desc.className = 'notif-desc';
desc.textContent = item.description;
content.appendChild(name);
content.appendChild(desc);
const time = document.createElement('div');
time.className = 'notif-time';
time.textContent = formatTimeAgo(item.created_at || 0);
timeElements.set(time, item.created_at || 0);
const right = document.createElement('div');
right.className = 'notif-right';
const menuButton = document.createElement('button');
menuButton.className = 'notif-menu-btn';
menuButton.type = 'button';
menuButton.textContent = '⋯';
menuButton.title = 'Notification menu';
const menuPanel = document.createElement('div');
menuPanel.className = 'notif-menu-panel';
menuPanel.style.position = 'absolute';
menuPanel.style.right = '0';
menuPanel.style.top = '26px';
menuPanel.style.zIndex = '10';
menuPanel.style.display = 'none';
menuPanel.style.minWidth = '140px';
menuPanel.style.padding = '6px';
menuPanel.style.border = 'var(--border)';
menuPanel.style.borderRadius = 'var(--border-radius)';
menuPanel.style.background = 'var(--secondary-color)';
const blockButton = document.createElement('button');
blockButton.type = 'button';
blockButton.textContent = 'Block account';
blockButton.style.width = '100%';
blockButton.style.textAlign = 'left';
blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)';
blockButton.style.borderRadius = 'var(--button-border-radius)';
blockButton.style.background = 'var(--button-background-color)';
blockButton.style.color = 'var(--button-color)';
blockButton.style.padding = '6px 8px';
blockButton.style.cursor = 'pointer';
blockButton.addEventListener('click', async (event) => {
event.stopPropagation();
const actorPubkey = String(item?.actor?.pubkey || '').trim();
if (!actorPubkey) return;
const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`);
if (!ok) return;
try {
await blockActorPubkey(actorPubkey);
} catch (error) {
console.warn('[notifications] Failed to block account:', error?.message || error);
} finally {
menuPanel.style.display = 'none';
}
});
// Add interaction menu items (Like, Comment, Quote, Zap, Nutzap) when
// the notification references a target post. The actual interaction
// logic (publishing, zap dialogs, balance checks) is handled by the
// post-interactions module via a hidden interaction bar appended to
// the row. Menu items trigger clicks on the corresponding buttons
// in that hidden bar.
if (item.targetEventId && interactions) {
const targetPubkey = item.targetPubkey || currentPubkey;
const targetEventData = { id: item.targetEventId, pubkey: targetPubkey };
const hiddenBar = interactions.renderInteractionBar(
item.targetEventId,
targetEventData,
{ currentPubkey }
);
hiddenBar.style.display = 'none';
hiddenBar.dataset.notifHiddenBar = item.targetEventId;
row.appendChild(hiddenBar);
const addMenuBtn = (label, selector) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'notif-menu-item';
btn.textContent = label;
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
const target = hiddenBar.querySelector(selector);
if (target) target.click();
menuPanel.style.display = 'none';
});
menuPanel.appendChild(btn);
};
addMenuBtn('Like', '.interaction-item.like');
// Comment opens the post-feed thread page in a new tab.
const commentBtn = document.createElement('button');
commentBtn.type = 'button';
commentBtn.className = 'notif-menu-item';
commentBtn.textContent = 'Comment';
commentBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
window.open('./post-feed.html?event=' + encodeURIComponent(item.targetEventId), '_blank');
menuPanel.style.display = 'none';
});
menuPanel.appendChild(commentBtn);
addMenuBtn('Quote', '.interaction-item.quote');
addMenuBtn('Zap', '.interaction-item.zap');
addMenuBtn('Nutzap', '.interaction-item.nutzap');
const divider = document.createElement('hr');
divider.className = 'notif-menu-divider';
menuPanel.appendChild(divider);
}
menuPanel.appendChild(blockButton);
right.style.position = 'relative';
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none';
});
right.appendChild(menuButton);
right.appendChild(menuPanel);
right.appendChild(time);
row.appendChild(avatar);
row.appendChild(secondaryNode);
row.appendChild(content);
row.appendChild(right);
return row;
}
function wireNotificationInteractions() {
if (!interactions) return;
const targetIds = Array.from(new Set(
Array.from(notificationsById.values())
.map((item) => item.targetEventId)
.filter(Boolean)
));
if (targetIds.length === 0) return;
interactions.fetchExistingInteractions(targetIds, { currentPubkey });
if (notifInteractionsSubId) {
interactions.unsubscribeFromInteractions(notifInteractionsSubId);
notifInteractionsSubId = null;
}
notifInteractionsSubId = interactions.subscribeToInteractions(targetIds, {
currentPubkey
});
}
function renderNotifications() {
const items = Array.from(notificationsById.values())
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
@@ -892,172 +1208,7 @@ import { createProfileCache } from './js/profile-cache.mjs';
timeElements.clear();
for (const item of visibleItems) {
const row = document.createElement('div');
row.className = 'notif-item';
const avatar = document.createElement('img');
avatar.className = 'notif-avatar';
avatar.alt = '';
avatar.src = item.actor.picture || './favicon/favicon-dots2.ico';
avatar.onerror = () => {
console.warn('[notifications] actor avatar failed to load', {
eventId: item.id,
actor: item.actor.name,
src: avatar.src
});
avatar.onerror = null;
avatar.src = './favicon/favicon-dots2.ico';
};
if (item.actor?.pubkey) {
avatar.style.cursor = 'pointer';
avatar.title = 'Open profile';
avatar.addEventListener('click', (event) => {
event.stopPropagation();
const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`;
window.open(profileUrl, '_blank', 'noopener,noreferrer');
});
}
const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || '');
const secondaryEventUrl = makeEventPermalink(item.targetEventId);
let secondaryNode;
if (secondaryImageUrl) {
const secondaryAvatar = document.createElement('img');
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
secondaryAvatar.alt = '';
secondaryAvatar.src = secondaryImageUrl;
secondaryAvatar.onerror = () => {
console.debug('[notifications] secondary image failed to load', {
eventId: item.id,
src: secondaryAvatar.src
});
secondaryAvatar.style.visibility = 'hidden';
};
if (secondaryEventUrl) {
secondaryAvatar.style.cursor = 'pointer';
secondaryAvatar.title = 'Open related event';
secondaryAvatar.addEventListener('click', (event) => {
event.stopPropagation();
window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer');
});
}
secondaryNode = secondaryAvatar;
} else if (secondaryEventUrl) {
const secondaryPlaceholder = document.createElement('a');
secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder';
secondaryPlaceholder.href = secondaryEventUrl;
secondaryPlaceholder.target = '_blank';
secondaryPlaceholder.rel = 'noopener noreferrer';
secondaryPlaceholder.title = 'Open related event';
secondaryPlaceholder.setAttribute('aria-label', 'Open related event');
secondaryPlaceholder.addEventListener('click', (event) => {
event.stopPropagation();
});
secondaryNode = secondaryPlaceholder;
} else {
const secondaryAvatar = document.createElement('div');
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
secondaryAvatar.style.visibility = 'hidden';
secondaryNode = secondaryAvatar;
}
const content = document.createElement('div');
content.className = 'notif-content';
const name = document.createElement('div');
name.className = 'notif-name';
name.textContent = item.actor.name;
const desc = document.createElement('div');
desc.className = 'notif-desc';
desc.textContent = item.description;
content.appendChild(name);
content.appendChild(desc);
const time = document.createElement('div');
time.className = 'notif-time';
time.textContent = formatTimeAgo(item.created_at || 0);
timeElements.set(time, item.created_at || 0);
const right = document.createElement('div');
right.className = 'notif-right';
const menuButton = document.createElement('button');
menuButton.className = 'notif-menu-btn';
menuButton.type = 'button';
menuButton.textContent = '⋯';
menuButton.title = 'Notification menu';
const menuPanel = document.createElement('div');
menuPanel.style.position = 'absolute';
menuPanel.style.right = '0';
menuPanel.style.top = '26px';
menuPanel.style.zIndex = '10';
menuPanel.style.display = 'none';
menuPanel.style.minWidth = '140px';
menuPanel.style.padding = '6px';
menuPanel.style.border = 'var(--border)';
menuPanel.style.borderRadius = 'var(--border-radius)';
menuPanel.style.background = 'var(--secondary-color)';
const blockButton = document.createElement('button');
blockButton.type = 'button';
blockButton.textContent = 'Block account';
blockButton.style.width = '100%';
blockButton.style.textAlign = 'left';
blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)';
blockButton.style.borderRadius = 'var(--button-border-radius)';
blockButton.style.background = 'var(--button-background-color)';
blockButton.style.color = 'var(--button-color)';
blockButton.style.padding = '6px 8px';
blockButton.style.cursor = 'pointer';
blockButton.addEventListener('click', async (event) => {
event.stopPropagation();
const actorPubkey = String(item?.actor?.pubkey || '').trim();
if (!actorPubkey) return;
const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`);
if (!ok) return;
try {
await blockActorPubkey(actorPubkey);
} catch (error) {
console.warn('[notifications] Failed to block account:', error?.message || error);
} finally {
menuPanel.style.display = 'none';
}
});
menuPanel.appendChild(blockButton);
right.style.position = 'relative';
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (event) => {
if (!right.contains(event.target)) {
menuPanel.style.display = 'none';
}
});
right.appendChild(menuButton);
right.appendChild(menuPanel);
right.appendChild(time);
row.appendChild(avatar);
row.appendChild(secondaryNode);
row.appendChild(content);
row.appendChild(right);
const row = buildNotificationRow(item);
divNotifications.appendChild(row);
}
@@ -1069,6 +1220,61 @@ import { createProfileCache } from './js/profile-cache.mjs';
updateHint();
refreshTimes();
renderNotificationFilters();
wireNotificationInteractions();
}
function prependNotificationRow(item) {
if (divNotifications.querySelector(`[data-notif-id="${CSS.escape(String(item.id))}"]`)) {
return true;
}
if (divNotifications.children.length === 0) {
renderNotifications();
return false;
}
const row = buildNotificationRow(item);
const newCreatedAt = Number(item.created_at || 0);
let inserted = false;
for (const existing of divNotifications.children) {
const existingCreatedAt = Number(existing.dataset?.notifCreatedAt || 0);
if (newCreatedAt > existingCreatedAt) {
divNotifications.insertBefore(row, existing);
inserted = true;
break;
}
}
if (!inserted) {
divNotifications.appendChild(row);
}
while (divNotifications.children.length > visibleNotificationsCount) {
const last = divNotifications.lastElementChild;
if (last) {
const timeEl = last.querySelector('.notif-time');
if (timeEl) timeElements.delete(timeEl);
last.remove();
} else {
break;
}
}
const total = notificationsById.size;
const hasMore = total > visibleNotificationsCount;
if (btnViewMoreNotifications) {
btnViewMoreNotifications.style.display = hasMore ? 'block' : 'none';
}
updateHint();
refreshTimes();
if (item.targetEventId && interactions) {
interactions.fetchExistingInteractions([item.targetEventId], { currentPubkey });
}
return true;
}
async function blockActorPubkey(pubkey) {
@@ -1092,6 +1298,29 @@ import { createProfileCache } from './js/profile-cache.mjs';
renderNotifications();
}
function pruneNotificationMaps() {
if (notificationsById.size > MAX_STORED_NOTIFICATIONS) {
const sorted = Array.from(notificationsById.values())
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const keep = new Set(sorted.slice(0, MAX_STORED_NOTIFICATIONS).map((n) => n.id));
for (const id of Array.from(notificationsById.keys())) {
if (!keep.has(id)) {
notificationsById.delete(id);
unreadNotificationsById.delete(id);
}
}
}
if (postImageCache.size > MAX_POST_IMAGE_CACHE) {
const excess = postImageCache.size - MAX_POST_IMAGE_CACHE;
let removed = 0;
for (const key of Array.from(postImageCache.keys())) {
if (removed >= excess) break;
postImageCache.delete(key);
removed++;
}
}
}
async function addNotificationFromEvent(evt) {
if (!evt || !evt.id || !evt.pubkey) return;
if (evt.pubkey === currentPubkey) return;
@@ -1127,7 +1356,46 @@ import { createProfileCache } from './js/profile-cache.mjs';
const targetEventId = getNotificationTargetEventId(evt);
notificationsById.set(evt.id, {
// Resolve the target event's pubkey so interaction handlers (like,
// zap, nutzap) target the correct post author. Most notifications
// reference the current user's own post, so default to currentPubkey
// and refine from cache/relays when available.
let targetPubkey = currentPubkey;
if (targetEventId) {
if (targetEventCache.has(targetEventId)) {
const cachedTarget = targetEventCache.get(targetEventId);
if (cachedTarget?.pubkey) targetPubkey = cachedTarget.pubkey;
} else {
try {
const cachedRefs = await queryCache({ ids: [targetEventId], limit: 1 });
const targetEvt = Array.isArray(cachedRefs)
? cachedRefs.find((e) => e?.id === targetEventId)
: null;
if (targetEvt) {
targetEventCache.set(targetEventId, targetEvt);
if (targetEvt.pubkey) targetPubkey = targetEvt.pubkey;
}
} catch (_) {}
// Async relay hydration (non-blocking).
void ndkFetchEvents({ ids: [targetEventId], limit: 1 })
.then((relayRefs) => {
const relayEvt = Array.isArray(relayRefs)
? relayRefs.find((e) => e?.id === targetEventId)
: null;
if (relayEvt) {
targetEventCache.set(targetEventId, relayEvt);
const existing = notificationsById.get(evt.id);
if (existing && existing.targetPubkey !== relayEvt.pubkey) {
existing.targetPubkey = relayEvt.pubkey;
}
}
})
.catch(() => {});
}
}
const notifItem = {
id: evt.id,
created_at: createdAt,
kind,
@@ -1135,10 +1403,21 @@ import { createProfileCache } from './js/profile-cache.mjs';
secondaryImage,
description,
targetEventId,
targetPubkey,
isUnread: createdAt > notificationsReadAt
});
};
renderNotifications();
notificationsById.set(evt.id, notifItem);
if (divNotifications.children.length > 0) {
prependNotificationRow(notifItem);
} else {
renderNotifications();
}
if (notificationsById.size > MAX_STORED_NOTIFICATIONS + 100) {
pruneNotificationMaps();
}
}
function eventTargetsCurrentPubkey(evt) {
@@ -1162,6 +1441,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
await addNotificationFromEvent(evt);
}
pruneNotificationMaps();
console.log('[notifications] cache preload complete', {
cachedCount: Array.isArray(cached) ? cached.length : 0,
loadedCount: filtered.length
@@ -1184,6 +1465,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
await addNotificationFromEvent(evt);
}
pruneNotificationMaps();
console.log('[notifications] relay hydration complete', {
relayCount: sorted.length
});
@@ -1275,6 +1558,11 @@ import { createProfileCache } from './js/profile-cache.mjs';
notificationsSub = null;
}
if (notifInteractionsSubId && interactions) {
interactions.unsubscribeFromInteractions(notifInteractionsSubId);
notifInteractionsSubId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
@@ -1297,6 +1585,19 @@ import { createProfileCache } from './js/profile-cache.mjs';
(async function main() {
try {
initHamburgerMenu();
document.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('.notif-right')) return;
const panels = document.querySelectorAll('.notif-menu-panel');
for (const panel of panels) {
if (panel.style.display !== 'none') {
panel.style.display = 'none';
}
}
});
syncNotificationFiltersFromSettings({});
const divSvgHam = document.getElementById('divSvgHam');

View File

@@ -158,7 +158,7 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
<span id="spanBroadcastStatus"></span>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
@@ -240,6 +240,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
const MIN_BOOTSTRAP_POSTS = 10;
const BOOTSTRAP_WINDOWS_SECONDS = [24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 90 * 24 * 3600];
const BOOTSTRAP_PER_WINDOW_LIMIT = 120;
const MAX_STORED_POSTS = 500;
const urlParams = new URLSearchParams(window.location.search);
const profileParamRaw = (urlParams.get('profile') || '').trim();
@@ -612,11 +613,24 @@ import { initPostCards } from './js/post-interactions2.mjs';
return eTags.every(t => t[3] === 'mention');
}
function prunePostsArray() {
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
if (posts.length > MAX_STORED_POSTS) {
posts.length = MAX_STORED_POSTS;
postIds.clear();
posts.forEach((p) => postIds.add(p.id));
for (const id of Array.from(renderedPostIds)) {
if (!postIds.has(id)) renderedPostIds.delete(id);
}
}
}
function upsertPost(evt) {
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
if (isEventMuted(evt)) return false;
postIds.add(evt.id);
posts.push(evt);
if (!isEventMode && posts.length > MAX_STORED_POSTS + 50) prunePostsArray();
return true;
}
@@ -652,6 +666,68 @@ import { initPostCards } from './js/post-interactions2.mjs';
divFooterRight.textContent = `${posts.length} posts`;
}
function prependPostCard(post) {
if (!post?.id || renderedPostIds.has(post.id)) return false;
// Event mode should never prepend — it uses renderSingleEvent / renderFeed.
if (isEventMode) {
renderFeed();
return false;
}
// If feed is empty or not yet rendered, fall back to full rebuild.
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
renderFeed();
return false;
}
const postCreatedAt = post.created_at || 0;
// Find the correct insertion point by scanning visible children's created_at.
// Posts are displayed newest-first, so we insert before the first child
// that is older than (or equal to) the new post.
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
let inserted = false;
for (const child of divFeed.children) {
const childId = child.getAttribute('data-post-id');
if (!childId) continue;
const childPost = posts.find((p) => p.id === childId);
if (!childPost) continue;
const childCreatedAt = childPost.created_at || 0;
if (postCreatedAt >= childCreatedAt) {
divFeed.insertBefore(postEl, child);
inserted = true;
break;
}
}
if (!inserted) {
// Post is older than all visible posts — append at the bottom.
divFeed.appendChild(postEl);
}
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.
while (divFeed.children.length > displayCount) {
const lastChild = divFeed.lastElementChild;
if (!lastChild) break;
const removedId = lastChild.getAttribute('data-post-id');
if (removedId) renderedPostIds.delete(removedId);
divFeed.removeChild(lastChild);
}
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
divHint.textContent = posts.length > 0
? `${posts.length} posts`
: (isProfileMode ? 'No posts yet.' : 'No posts yet — write one above.');
divFooterRight.textContent = `${posts.length} posts`;
return true;
}
function renderSingleEvent(evt) {
if (!evt?.id) return;
posts.length = 0;
@@ -810,18 +886,38 @@ 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();
});
await initNDKPage();
// Live broadcast progress display in the footer center section.
// The worker streams per-relay publish results via this window event
// (see plans/broadcast-relays-feature.md §8).
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || !spanBroadcastStatus) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
const batchInfo = (d.batchTotal && d.batchTotal > 0)
? ` · ${d.batchCurrent}/${d.batchTotal} batches`
: '';
const relayInfo = shortRelay ? ` · ${shortRelay}` : '';
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays${batchInfo}${relayInfo}`;
} else if (d.phase === 'done') {
// Keep the result displayed until the next publish overwrites it.
spanBroadcastStatus.textContent = `${d.successful}/${d.total} relays` + (d.failed > 0 ? ` (${d.failed} failed)` : '');
}
});
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
@@ -889,7 +985,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
}
if (!upsertPost(evt)) return;
renderFeed();
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
prependPostCard(evt);
} else {
renderFeed();
}
});
if (isEventMode) {

View File

@@ -342,8 +342,19 @@
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import {
loadBroadcastRelays,
addBroadcastRelay,
removeBroadcastRelay,
seedFromDiscovered,
unskipRelay,
onBroadcastRelaysChanged,
getActiveBroadcastRelays,
getSkippedBroadcastRelays,
} from './js/broadcast-relays.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[relays.html ${VERSION}] Loading...`);
@@ -430,6 +441,33 @@ const versionInfo = await getVersion();
<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>
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
<span style="flex:1;">Broadcast Relays
(<span id="spanBroadcastActive">0</span> active,
<span id="spanBroadcastSkipped">0</span> skipped)
</span>
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
</div>
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
style="flex:1;font-size:80%;padding:2px 4px;" />
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
</div>
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
Add all discovered relays
</button>
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
</div>
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
Save to Nostr (kind 10088)
</button>
<div id="divBroadcastStatus" style="font-size:75%;margin-top:4px;min-height:1em;"></div>
</div>
</div>
`;
// Wire up the connection history toggle
@@ -462,7 +500,14 @@ const versionInfo = await getVersion();
});
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
}
// Wire up the Broadcast Relays collapsible section.
initBroadcastRelaysSection();
// Refresh broadcast relay data from the worker every time the sidenav
// opens so the user sees the freshest active/skipped lists.
loadBroadcastRelays();
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
@@ -507,6 +552,159 @@ const versionInfo = await getVersion();
}
}
/* ================================================================
BROADCAST RELAYS SECTION (kind 10088)
================================================================ */
let broadcastRelaysSectionWired = false;
function initBroadcastRelaysSection() {
// Toggle header click → show/hide content.
const header = document.getElementById('divBroadcastRelaysHeader');
const content = document.getElementById('divBroadcastRelaysContent');
const icon = document.getElementById('divBroadcastToggleIcon');
if (header && content) {
header.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
if (icon) icon.textContent = isOpen ? '▸' : '▾';
});
}
// Add button.
const btnAdd = document.getElementById('btnBroadcastAdd');
const txtAdd = document.getElementById('txtBroadcastAdd');
if (btnAdd && txtAdd) {
const doAdd = async () => {
const url = txtAdd.value.trim();
if (!url) return;
txtAdd.value = '';
setBroadcastStatus('Adding…');
await addBroadcastRelay(url);
setBroadcastStatus('Added');
setTimeout(() => setBroadcastStatus(''), 2000);
};
btnAdd.addEventListener('click', doAdd);
txtAdd.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); doAdd(); }
});
}
// Seed from discovered relays button.
const btnSeed = document.getElementById('btnBroadcastSeed');
if (btnSeed) {
btnSeed.addEventListener('click', async () => {
setBroadcastStatus('Seeding from discovered relays…');
try {
const added = await seedFromDiscovered();
setBroadcastStatus(added > 0
? `Added ${added} relay${added === 1 ? '' : 's'}`
: 'No new relays to add');
} catch (err) {
setBroadcastStatus('Seed failed: ' + (err?.message || err));
}
setTimeout(() => setBroadcastStatus(''), 4000);
});
}
// Save button (the worker auto-saves on add/remove, but this gives the
// user an explicit "flush to Nostr" action as the plan specifies).
const btnSave = document.getElementById('btnBroadcastSave');
if (btnSave) {
btnSave.addEventListener('click', async () => {
setBroadcastStatus('Saving to Nostr (kind 10088)…');
try {
// Re-save the current active list to force a fresh kind 10088
// publish (the worker deduplicates/merges with existing r-tags).
const { saveBroadcastRelays } = await import('./js/init-ndk.mjs');
await saveBroadcastRelays(getActiveBroadcastRelays());
setBroadcastStatus('Saved');
} catch (err) {
setBroadcastStatus('Save failed: ' + (err?.message || err));
}
setTimeout(() => setBroadcastStatus(''), 3000);
});
}
// Subscribe to changes from the wrapper module (re-render).
if (!broadcastRelaysSectionWired) {
broadcastRelaysSectionWired = true;
onBroadcastRelaysChanged(() => renderBroadcastRelaysSection());
// Cross-tab / cross-client sync: the worker forwards kind 10088
// subscription updates as this window event.
window.addEventListener('ndkBroadcastRelaysUpdated', () => {
loadBroadcastRelays();
});
}
renderBroadcastRelaysSection();
}
function setBroadcastStatus(text) {
const el = document.getElementById('divBroadcastStatus');
if (el) el.textContent = text || '';
}
function renderBroadcastRelaysSection() {
const spanActive = document.getElementById('spanBroadcastActive');
const spanSkipped = document.getElementById('spanBroadcastSkipped');
const listEl = document.getElementById('divBroadcastList');
const skippedEl = document.getElementById('divBroadcastSkippedList');
if (!listEl || !skippedEl) return;
const activeUrls = getActiveBroadcastRelays();
const skipped = getSkippedBroadcastRelays();
if (spanActive) spanActive.textContent = String(activeUrls.length);
if (spanSkipped) spanSkipped.textContent = String(skipped.length);
// Active list
if (activeUrls.length === 0) {
listEl.innerHTML = '<div style="font-size:75%;opacity:0.6;">No active broadcast relays.</div>';
} else {
listEl.innerHTML = activeUrls.map((url) => `
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
<span style="flex:1;word-break:break-all;">${escapeHtml(url)}</span>
<button data-broadcast-remove="${escapeHtml(url)}" style="font-size:75%;padding:0 4px;">×</button>
</div>
`).join('');
listEl.querySelectorAll('[data-broadcast-remove]').forEach((btn) => {
btn.addEventListener('click', () => removeBroadcastRelay(btn.getAttribute('data-broadcast-remove')));
});
}
// Skipped list
if (skipped.length === 0) {
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div><div style="font-size:75%;">None skipped.</div>';
} else {
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>' +
skipped.map((item) => {
const ts = item.timestamp
? new Date(item.timestamp * 1000).toLocaleString()
: '';
return `
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
<span style="flex:1;word-break:break-all;">
${escapeHtml(item.url)}
<div style="font-size:70%;opacity:0.8;">${escapeHtml(item.reason || 'failed')}${ts ? ' · ' + escapeHtml(ts) : ''}</div>
</span>
<button data-broadcast-unskip="${escapeHtml(item.url)}" style="font-size:75%;padding:0 4px;">Unskip</button>
</div>
`;
}).join('');
skippedEl.querySelectorAll('[data-broadcast-unskip]').forEach((btn) => {
btn.addEventListener('click', () => unskipRelay(btn.getAttribute('data-broadcast-unskip')));
});
}
}
function escapeHtml(s) {
const map = { '&': 38, '<': 60, '>': 62, '"': 34, "'": 39 };
return String(s || '').replace(/[&<>"']/g, (c) =>
'&#' + map[c] + ';'
);
}
/* ================================================================
RELAY TABLE FUNCTIONS
================================================================ */
@@ -1534,6 +1732,10 @@ const versionInfo = await getVersion();
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Load broadcast relays from the worker so the sidenav shows fresh
// data the first time it is opened.
loadBroadcastRelays();
// Get authenticated pubkey
currentPubkey = await getPubkey();