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 +
+ ... +
+ +
+
+ Broadcast Relays + (0 active, + 0 skipped) + + +
+ +
+``` + +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 @@
- +
0 sats
@@ -810,18 +810,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) + : ''; + 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)` : ''); + setTimeout(() => { + if (spanBroadcastStatus.textContent.startsWith('✅')) { + spanBroadcastStatus.textContent = ''; + } + }, 10000); + } + }); + currentPubkey = await getPubkey(); await injectHeaderAvatar(currentPubkey); diff --git a/www/relays.html b/www/relays.html index 192c1e2..b19d776 100644 --- a/www/relays.html +++ b/www/relays.html @@ -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(); Show connection history
${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}
+
+
+ Broadcast Relays + (0 active, + 0 skipped) + + +
+ +
`; // 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 = '
No active broadcast relays.
'; + } else { + listEl.innerHTML = activeUrls.map((url) => ` +
+ ${escapeHtml(url)} + +
+ `).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 = '
Skipped (failed publishes):
None skipped.
'; + } else { + skippedEl.innerHTML = '
Skipped (failed publishes):
' + + skipped.map((item) => { + const ts = item.timestamp + ? new Date(item.timestamp * 1000).toLocaleString() + : ''; + return ` +
+ + ${escapeHtml(item.url)} +
${escapeHtml(item.reason || 'failed')}${ts ? ' · ' + escapeHtml(ts) : ''}
+
+ +
+ `; + }).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();