diff --git a/plans/broadcast-relays-feature.md b/plans/broadcast-relays-feature.md
new file mode 100644
index 0000000..adfb738
--- /dev/null
+++ b/plans/broadcast-relays-feature.md
@@ -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
+
+```
+
+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
+
+
+
+
+```
+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
+
+
+
+```
+
+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.
diff --git a/www/js/broadcast-relays.mjs b/www/js/broadcast-relays.mjs
new file mode 100644
index 0000000..b93528f
--- /dev/null
+++ b/www/js/broadcast-relays.mjs
@@ -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 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');
diff --git a/www/js/init-ndk.mjs b/www/js/init-ndk.mjs
index 0242328..9166a1b 100644
--- a/www/js/init-ndk.mjs
+++ b/www/js/init-ndk.mjs
@@ -373,6 +373,27 @@ 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,
+ }
+ }));
} else if (message.type === 'startupStatus') {
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
} else if (message.type === 'relayEvent') {
@@ -725,6 +746,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
diff --git a/www/js/version.json b/www/js/version.json
index 6163212..a8fc324 100644
--- a/www/js/version.json
+++ b/www/js/version.json
@@ -1,5 +1,5 @@
{
- "VERSION": "v0.7.65",
- "VERSION_NUMBER": "0.7.65",
- "BUILD_DATE": "2026-06-29T00:46:57.220Z"
+ "VERSION": "v0.7.66",
+ "VERSION_NUMBER": "0.7.66",
+ "BUILD_DATE": "2026-06-30T11:47:22.403Z"
}
diff --git a/www/ndk-worker.js b/www/ndk-worker.js
index 42fc92d..a2c144c 100644
--- a/www/ndk-worker.js
+++ b/www/ndk-worker.js
@@ -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} 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} 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 || []) {
@@ -5181,6 +5458,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 +6148,159 @@ 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.
+ let targetRelaySet = null;
+ 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 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)`);
+ } else {
+ console.warn('[Worker] NDKRelaySet.fromRelayUrls unavailable, falling back to default outbox');
+ }
+ }
+
+ // --- 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 all pages via
+ // broadcast() so footers can show "📡 42/150 relays · relay.example.com".
+ 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
+ ? targetRelaySet.relays.size
+ : (targetRelaySet ? (targetRelaySet.size || 0) : 0);
+
+ if (isBroadcast && totalTarget > 0) {
+ 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;
+ publishedSoFar.add(url);
+ trackRelayWrite(url);
+ broadcast({
+ type: 'broadcastProgress',
+ sessionId: publishSessionId,
+ phase: 'progress',
+ total: totalTarget,
+ successful: publishedSoFar.size,
+ failed: failedSoFar.size,
+ latestRelay: url,
+ });
+ });
+
+ ndkEvent.on('relay:publish:failed', (relay, err) => {
+ const url = relay?.url || String(relay || '');
+ if (!url) return;
+ failedSoFar.add(url);
+ broadcast({
+ type: 'broadcastProgress',
+ sessionId: publishSessionId,
+ phase: 'progress',
+ total: totalTarget,
+ successful: publishedSoFar.size,
+ failed: failedSoFar.size,
+ latestRelay: url,
+ latestError: err?.message || String(err),
+ });
+ });
+ }
+
+ // Publish to relays (with broadcast relay set if broadcasting, else default outbox)
+ 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 so the NDKEvent can be garbage-collected.
+ try {
+ ndkEvent.removeAllListeners('relay:published');
+ ndkEvent.removeAllListeners('relay:publish:failed');
+ } catch (_cleanupErr) {
+ // removeAllListeners may not exist on all EventEmitter impls; ignore.
+ }
+ }
+
// 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);
}
}
-
+
console.log('[Worker] ✅ Published to relays:', relayResults.successful);
console.log('[Worker] ❌ Failed relays:', relayResults.failed);
-
+
+ // --- Failure marking for broadcast relays ---------------------------------
+ // Persist skip-marks for broadcast relays that failed this publish so
+ // future publishes skip them. Debounced via scheduleSkipMarkSave() so a
+ // single post that fails on many relays produces one kind 10088 republish.
+ 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),
+ });
+ }
+ scheduleSkipMarkSave();
+ console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays`);
+ }
+ }
+
// 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 +7457,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');
diff --git a/www/post.html b/www/post.html
index 2f32b0d..654f5f1 100644
--- a/www/post.html
+++ b/www/post.html
@@ -158,7 +158,7 @@