# Broadcast Relays Feature

## Goal

Allow the user to publish posts to **many more relays** than their standard
NIP-65 outbox — potentially hundreds — by maintaining a separate "broadcast
relay list" that is **unioned into every public publish** automatically.

This mirrors Amethyst's `BroadcastRelayListEvent` (NIP-51 kind 10088) and uses
NDK's `NDKRelaySet.fromRelayUrls()` primitive, which the worker already
exercises in `handlePublishRawToRelay`.

## Design decisions (confirmed with user)

1. **Always-on union** — broadcast relays are automatically unioned into every
   public publish. No per-post toggle. (Matches Amethyst's default.)
2. **Manage on `relays.html`** — a new "Broadcast Relays" section alongside
   "Discovered Relays" for add/remove/save.
3. **One-click seed from discovered relays** — a button on `relays.html` bulk-
   adds the discovered-relays URLs (from follows' kind 10002) to the 10088
   list. User stays in control of when it happens.
4. **Persistent skip-marks in kind 10088** — when a relay fails to publish
   (auth-required, unreachable, etc.), it stays in the list but is marked with
   a separate `["x", url]` tag so we skip it on future publishes. See
   "Skip-mark coexistence with Amethyst" below.

## Background — what each codebase already does

### Amethyst (`~/lt/amethyst`)

- [`BroadcastRelayListEvent.kt`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt:43)
  defines **kind 10088** — a NIP-51 `PrivateTagArrayEvent`. Relay URLs live as
  `["r", url]` tags inside a NIP-44-encrypted `content` blob (private list),
  with an optional public-tag variant. It is replaceable per pubkey
  (`d = ""`).
- [`AccountOutboxRelayState.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt:41)
  **unions** the broadcast set into the outbox:
  `nip65Outbox + privateOutBox + localRelays + broadcastRelays`.
- [`Account.kt:1266`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1266)
  gates inclusion with `wantsBroadcastRelays(event)` — public posts/reactions/
  reposts get the broadcast set; drafts, app settings, bookmarks, channels with
  their own relays, and DMs do **not**.
- [`Account.kt:1715`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1715)
  `sendLiterallyEverywhere()` is a separate "publish to everything" path used
  only for profile/identity bootstrap events — **not** what we want for posts.

### This client (`/home/user/lt/client`)

- [`publishEvent`](www/js/init-ndk.mjs:629) → worker
  [`handlePublish`](www/ndk-worker.js:5810) → `ndkEvent.publish()` with **no
  relaySet** → NDK outbox routing sends only to the user's kind 10002 write
  relays.
- [`publishRawEventToRelay`](www/js/init-ndk.mjs:688) → worker
  [`handlePublishRawToRelay`](www/ndk-worker.js:5983) already demonstrates the
  exact NDK primitive we need:
  ```js
  NDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk)
  await ndkEvent.publish(targetRelaySet)
  ```
  NDK's `fromRelayUrls` (see `ndk-core.bundle.js:9245`) accepts an **array**
  and uses **temporary relays** (`pool.useTemporaryRelay`) so hundreds of URLs
  work without permanently polluting the connection pool.
- [`mute-list.mjs`](www/js/mute-list.mjs:41) establishes the
  NIP-44-encrypted-private-list pattern we will mirror:
  `configureMuteList({ nip44Encrypt, nip44Decrypt, publishEvent, getPubkey })`.
- The post composer ([`post-composer.mjs:740`](www/js/post-composer.mjs:740))
  calls `onSubmit(text, {attachments})` → pages call
  `publishEvent({kind:1,...})`. No composer change is needed for the always-on
  design.
- No kind 10088 / broadcast-relay concept exists yet.

## Skip-mark coexistence with Amethyst

Amethyst's [`RelayTag`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt:28)
only matches `tag[0] == "r"` and reads `tag[1]` — it **ignores** any third
element. Its `updateRelayList` calls `tags.remove(RelayTag::match)` which only
strips `r` tags, **preserving** any non-`r` tags through Amethyst's updates.

This means we can safely store skip-marks in the **same kind 10088 event** as
a separate tag type that Amethyst will never touch:

```
// Active broadcast relay (Amethyst reads this):
["r", "wss://relay.example"]

// Skip-marked relay (Amethyst ignores — not an "r" tag):
["x", "wss://private.relay.example", "auth-required", "1700000000"]
//  ^  ^                         ^                ^
//  |  url                       reason           timestamp of failure
```

**Rules:**
- Our client reads **both** `r` and `x` tags from kind 10088.
- `r` tags = active broadcast relays (unioned into publishes).
- `x` tags = skip-marked relays (kept in the list for visibility, but excluded
  from the publish relay set).
- When Amethyst updates the event, it only touches `r` tags — our `x` tags
  survive untouched.
- When our client updates the event, we preserve all existing `r` tags (so we
  don't clobber Amethyst's list) and only add/remove `x` tags.
- A skip-marked relay can be manually un-skipped from the UI (moves it back to
  `r`), or auto-un-skipped if a retry succeeds.

**Why not a third element on the `r` tag?** Amethyst's `RelayTag.parse()`
ignores `tag[2]`, so `["r", url, "skipped"]` would technically survive parsing.
But Amethyst's `updateRelayList` does `tags.remove(RelayTag::match)` which
removes **all** `r` tags before re-adding the new list — so any third-element
annotation would be **wiped** on Amethyst's next save. A separate `x` tag is
the only safe coexistence strategy.

## Auto-seed from discovered relays

Your client already computes the perfect seed source:
[`handleGetDiscoveredRelays`](www/ndk-worker.js:6458) builds a
`relayServesPubkeys` map from all follows' kind 10002 `write`/`both` relays,
cached in the module-level `discoveredRelaysCache` variable, keyed by the
user's kind 3 contact list. The "Discovered Relays (Outbox)" section on
`relays.html` already displays this cache.

**No duplication** — the "Add all discovered relays" button reads the **same
`discoveredRelaysCache`** variable already populated by
`handleGetDiscoveredRelays`. No second query, no second cache. The two
features serve different purposes:
- **Discovered Relays** = read-only display of relays your follows use (for
  awareness, cached from their kind 10002 events).
- **Broadcast Relays** = your persistent kind 10088 list of relays YOU will
  publish to (user-curated, unioned into every publish).

The seed button is the one-way bridge: discovered → broadcast. The button
will:

1. Call the existing `getDiscoveredRelays` worker handler to get the cached
   list of `{url, servingPubkeys, ...}` objects.
2. Extract just the URLs.
3. Union them with the existing `r`-tag broadcast relays (skip any already
   present, skip any in the `x`-tag skip-mark set).
4. Save the merged list to kind 10088.

This is a **one-click, user-initiated** action — no automatic background
seeding. The user can re-run it after following new people to pick up their
relays.

## Architecture

```mermaid
flowchart LR
  subgraph Page
    PC[post-composer.mjs] -->|publishEvent kind:1| INIT[init-ndk.mjs]
    REL[relays.html] -->|saveBroadcastRelays / seedFromDiscovered| INIT
  end
  INIT -->|publish / saveBroadcastRelays| W[ndk-worker.js]
  subgraph Worker
    W --> HP[handlePublish]
    HP --> WBR{wantsBroadcastRelays?}
    WBR -->|yes| FILTER[active r-tags minus x-tag skips]
    WBR -->|no| DEFAULT[ndkEvent.publish default]
    FILTER --> UNION[union outbox + active broadcast urls]
    UNION --> RS[NDKRelaySet.fromRelayUrls]
    RS --> PUB[ndkEvent.publish relaySet]
    PUB --> MARK[on failure: add x-tag skip-mark to 10088]
  end
  subgraph Nostr
    PUB --> R10002[Kind 10002 write relays]
    PUB --> R10088[Kind 10088 broadcast relays - hundreds]
  end
  W -.->|subscribe kind 10088| R10088
  R10088 -.->|live update| W
```

## Implementation plan

### 1. Worker: broadcast-relay state + hydration (`www/ndk-worker.js`)

- Add module-level state:
  ```js
  let broadcastRelayUrls = new Set();      // active "r" tags
  let skippedRelayUrls = new Map();        // url -> { reason, timestamp }  ("x" tags)
  let latest10088Event = null;             // raw event for update-preserving saves
  ```
- On first init (after `fetchUserRelays`), fetch the user's kind 10088:
  ```js
  const filter = { kinds: [10088], authors: [pubkey], limit: 1 };
  const events = await ndk.fetchEvents(filter);
  // pick newest event → store as latest10088Event
  // Parse public tags: ["r", url] → broadcastRelayUrls
  // NIP-44-decrypt content → parse private tags:
  //   ["r", url] → broadcastRelayUrls (Amethyst stores relays privately)
  //   ["x", url, reason, timestamp] → skippedRelayUrls
  ```
  Reuse the existing `nip44Decrypt` plumbing the worker already has for the
  mute list. If decryption fails or no event exists, leave both sets empty.
- Subscribe to kind 10088 for the pubkey so live updates from other tabs/
  clients (including Amethyst) refresh both sets and broadcast a
  `broadcastRelaysUpdated` message to all ports (mirrors the existing
  `relay-types-updated` pattern at `ndk-worker.js:5177`).
- Add a helper `getActiveBroadcastUrls()` that returns
  `Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u))` —
  this is what `handlePublish` will use.

### 2. Worker: `wantsBroadcastRelays` gate + union in `handlePublish`

Add a helper near `handlePublish` (`ndk-worker.js:5810`):

```js
function wantsBroadcastRelays(event) {
  // Public, broadcast-worthy kinds. Mirror Amethyst's Account.kt:1217.
  const BROADCAST_KINDS = new Set([
    1,        // text note
    6,        // repost
    7,        // reaction
    30023,    // long-form article
    30024,    // draft long-form
    30315,    // status
    31123,    // public skill
    31124,    // private skill (still public event)
    10088,    // broadcast relay list itself
  ]);
  // Explicitly EXCLUDE: 4 (DM), 10002 (relay list), 10050 (DM inbox),
  // 30078 (app settings), 7375/17375 (wallet), 1059/13/14 (gift wrap/seal).
  return BROADCAST_KINDS.has(Number(event.kind));
}
```

Modify `handlePublish` after signing, before `ndkEvent.publish()`:

```js
let targetRelaySet = null;
const activeBroadcastUrls = getActiveBroadcastUrls(); // r-tags minus x-tag skips
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;

if (isBroadcast) {
  // Union the user's normal outbox write relays with the active broadcast set.
  const outboxWriteUrls = Array.from(relayTypes.entries())
    .filter(([_, t]) => t === 'write' || t === 'both')
    .map(([url]) => url);
  const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
  if (NDKRelaySet?.fromRelayUrls) {
    targetRelaySet = NDKRelaySet.fromRelayUrls(allUrls, ndk);
    console.log(`[Worker] Broadcasting to ${allUrls.length} relays ` +
      `(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
      `${skippedRelayUrls.size} skipped)`);
  }
}

// --- Live broadcast progress streaming ---
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per relay
// as each completes (see ndk-core.bundle.js:8530 and :8552). We attach
// listeners BEFORE publish() and stream progress to the page via broadcast().
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set();
const totalTarget = targetRelaySet ? targetRelaySet.relays.size : 0;

if (isBroadcast && totalTarget > 0) {
  // Announce the broadcast session to all pages.
  broadcast({
    type: 'broadcastProgress',
    sessionId: publishSessionId,
    eventKind: event.kind,
    eventId: ndkEvent.id || null,
    phase: 'start',
    total: totalTarget,
    successful: 0,
    failed: 0,
    latestRelay: null,
  });

  ndkEvent.on('relay:published', (relay) => {
    publishedSoFar.add(relay.url);
    trackRelayWrite(relay.url);
    broadcast({
      type: 'broadcastProgress',
      sessionId: publishSessionId,
      phase: 'progress',
      total: totalTarget,
      successful: publishedSoFar.size,
      failed: failedSoFar.size,
      latestRelay: relay.url,
    });
  });

  ndkEvent.on('relay:publish:failed', (relay, err) => {
    failedSoFar.add(relay.url);
    broadcast({
      type: 'broadcastProgress',
      sessionId: publishSessionId,
      phase: 'progress',
      total: totalTarget,
      successful: publishedSoFar.size,
      failed: failedSoFar.size,
      latestRelay: relay.url,
      latestError: err?.message || String(err),
    });
  });
}

const relaySet = await ndkEvent.publish(targetRelaySet);

// Final broadcast progress message with complete results.
if (isBroadcast && totalTarget > 0) {
  broadcast({
    type: 'broadcastProgress',
    sessionId: publishSessionId,
    phase: 'done',
    total: totalTarget,
    successful: publishedSoFar.size,
    failed: failedSoFar.size,
    latestRelay: null,
  });
  // Clean up listeners.
  ndkEvent.removeAllListeners('relay:published');
  ndkEvent.removeAllListeners('relay:publish:failed');
}
```

`NDKRelaySet.fromRelayUrls` uses `pool.useTemporaryRelay` for URLs not already
in the pool, so hundreds of broadcast relays connect transiently for the
publish and do not interfere with the user's read subscriptions.

**Failure marking** — after `ndkEvent.publish()` returns, the existing code at
[`ndk-worker.js:5856`](www/ndk-worker.js:5856) already computes
`relayResults.failed` (connected relays that didn't accept the event). Extend
it to persist skip-marks for broadcast relays that failed:

```js
// After the existing relayResults.failed computation:
if (isBroadcast) {
  const newSkips = relayResults.failed
    .filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
  if (newSkips.length > 0) {
    for (const url of newSkips) {
      skippedRelayUrls.set(url, {
        reason: 'publish-failed',
        timestamp: Math.floor(Date.now() / 1000),
      });
    }
    // Persist the updated skip-marks to kind 10088 (debounced — see step 3).
    scheduleSkipMarkSave();
    console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays`);
  }
}
```

The skip-mark save is **debounced** (e.g. 5s) so a single post that fails on
20 relays produces one kind 10088 republish, not 20. The save preserves all
existing `r` tags and only updates the `x` tags (see step 3).

### 3. Worker: `getBroadcastRelays` + `saveBroadcastRelays` + `seedBroadcastRelays` + skip-mark handlers

Add new message cases in the worker's `switch` (near `ndk-worker.js:6980`):

- `getBroadcastRelays` → respond with:
  ```js
  {
    active: Array.from(broadcastRelayUrls),
    skipped: Array.from(skippedRelayUrls.entries()).map(([url, info]) => ({ url, ...info })),
  }
  ```

- `saveBroadcastRelays` → build/update a kind 10088 event. **Critical: preserve
  existing `r` tags and all `x` tags** so we coexist with Amethyst:
  ```js
  // Start from latest10088Event if we have one (preserve Amethyst's r-tags
  // that the user might not have in our local set yet). Otherwise start fresh.
  const existingRTags = latest10088Event
    ? latest10088Event.tags.filter(t => t[0] === 'r')
    : [];
  // Merge: union the user's new urls with existing r-tags.
  const mergedRUrls = new Set([
    ...existingRTags.map(t => t[1]),
    ...urls, // the new active list from the UI
  ]);
  // Preserve existing x-tags (skip-marks) — never touch them on a user save.
  const existingXTags = latest10088Event
    ? latest10088Event.tags.filter(t => t[0] === 'x')
    : [];

  // NIP-44-encrypt the private tags (r + x) as the content.
  const privateTags = [
    ...Array.from(mergedRUrls).map(u => ['r', u]),
    ...existingXTags,
  ];
  const encrypted = await nip44Encrypt(currentPubkey, JSON.stringify(privateTags));
  const event = {
    kind: 10088,
    content: encrypted,
    tags: [],  // public tags empty — keep private like Amethyst's create()
    created_at: Math.floor(Date.now() / 1000),
  };
  // sign via signEventWithMessageSigner, then publish (default outbox).
  // Update broadcastRelayUrls locally + broadcast broadcastRelaysUpdated.
  ```

- `seedBroadcastRelays` → one-click "Add all discovered relays":
  ```js
  // Reuse the discovered-relays cache (handleGetDiscoveredRelays logic).
  // Get the cached list of {url, ...} objects.
  const discovered = discoveredRelaysCache || [];
  const discoveredUrls = discovered.map(r => r.url).filter(Boolean);
  // Union with existing active r-tags, excluding skip-marked relays.
  const newUrls = discoveredUrls.filter(u =>
    !broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
  );
  if (newUrls.length === 0) {
    port.postMessage({ type: 'response', requestId, data: { added: 0 } });
    return;
  }
  // Merge into broadcastRelayUrls and save.
  for (const u of newUrls) broadcastRelayUrls.add(u);
  await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
  port.postMessage({ type: 'response', requestId, data: { added: newUrls.length } });
  ```

- `unskipBroadcastRelay` → move a relay from the `x`-tag skip set back to
  active (user clicked "Retry" / "Unskip" in the UI):
  ```js
  skippedRelayUrls.delete(url);
  // No need to add to broadcastRelayUrls — it's already there (we keep
  // skipped relays in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls).
  await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
  ```

- `scheduleSkipMarkSave()` (internal, debounced) — called by `handlePublish`
  after failure marking. Same save logic as `saveBroadcastRelays` but only
  updates the `x` tags, preserving the current `r` tags. Debounce with a 5s
  timer so multiple failures in one publish produce one republish.

  Reuse the existing `signEventWithMessageSigner` and the existing
  `nip44Encrypt`/`nip44Decrypt` helpers the worker already wires for the mute
  list and NIP-17 messaging.

### 4. `init-ndk.mjs` exports + progress forwarding

Add alongside [`publishEvent`](www/js/init-ndk.mjs:629):

```js
export async function getBroadcastRelays() { /* sendWorkerRequest → {active, skipped} */ }
export async function saveBroadcastRelays(urls) { /* sendWorkerRequest */ }
export async function seedBroadcastRelays() { /* sendWorkerRequest → {added} */ }
export async function unskipBroadcastRelay(url) { /* sendWorkerRequest */ }
```

Forward two new worker message types to window events:

1. `broadcastRelaysUpdated` →
   `window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated', {detail}))`
   (same pattern as `ndkRelayActivity` at `ndk-worker.js:2673`).

2. `broadcastProgress` →
   `window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {detail}))`
   so pages can show live publish progress. The detail payload:
   ```js
   {
     sessionId, phase,    // 'start' | 'progress' | 'done'
     eventKind, eventId,
     total,               // total relays being published to
     successful,          // count so far
     failed,              // count so far
     latestRelay,         // URL of the most recent relay to respond
     latestError,         // error message if latestRelay failed
   }
   ```

### 5. New module `www/js/broadcast-relays.mjs`

Thin wrapper mirroring [`mute-list.mjs`](www/js/mute-list.mjs:41):

```js
import {
  getBroadcastRelays, saveBroadcastRelays,
  seedBroadcastRelays, unskipBroadcastRelay, getPubkey
} from './init-ndk.mjs';

let active = new Set();
let skipped = new Map();  // url -> { reason, timestamp }
const listeners = new Set();

export async function loadBroadcastRelays() {
  const { active: a, skipped: s } = await getBroadcastRelays();
  active = new Set(a);
  skipped = new Map(s.map(item => [item.url, item]));
  notify();
}

export function getActiveBroadcastRelays() { return Array.from(active); }
export function getSkippedBroadcastRelays() { return Array.from(skipped.values()); }
export function isBroadcastRelay(url) { return active.has(url); }

export async function addBroadcastRelay(url) {
  active.add(normalize(url));
  await saveBroadcastRelays(Array.from(active));
  notify();
}

export async function removeBroadcastRelay(url) {
  active.delete(normalize(url));
  await saveBroadcastRelays(Array.from(active));
  notify();
}

export async function seedFromDiscovered() {
  const { added } = await seedBroadcastRelays();
  await loadBroadcastRelays(); // refresh from worker
  return added;
}

export async function unskipRelay(url) {
  await unskipBroadcastRelay(url);
  await loadBroadcastRelays();
}

export function onBroadcastRelaysChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); }
function notify() { listeners.forEach(cb => cb({ active: Array.from(active), skipped: Array.from(skipped.values()) })); }
```

### 6. `relays.html` sidenav — broadcast relays management UI

The broadcast relays management UI lives in the **sidenav** below the "Show
connection history" toggle, not in the main body. The sidenav body is rendered
in `openNav()` at [`relays.html:428`](www/relays.html:428).

Extend the `divSideNavBody.innerHTML` template to add a broadcast relays
section below the connection history toggle:

```html
<div id="divRelaySettings" style="...">  <!-- existing: Show connection history -->
  ...
</div>
<!-- NEW: Broadcast relays section -->
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
  <div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
    <span style="flex:1;">Broadcast Relays
      (<span id="spanBroadcastActive">0</span> active,
       <span id="spanBroadcastSkipped">0</span> skipped)
    </span>
    <span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
  </div>
  <div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
    <div style="display:flex;gap:4px;margin-bottom:6px;">
      <input id="txtBroadcastAdd" placeholder="wss://relay.example"
             style="flex:1;font-size:80%;padding:2px 4px;" />
      <button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
    </div>
    <button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
      Add all discovered relays
    </button>
    <div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
    <div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
      <div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
    </div>
    <button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
      Save to Nostr (kind 10088)
    </button>
  </div>
</div>
```

Wire it with `broadcast-relays.mjs`:
- On `openNav()`: `loadBroadcastRelays()` → render active + skipped lists +
  update count badges.
- Toggle header click → show/hide `divBroadcastRelaysContent`.
- Add button → `addBroadcastRelay(url)` → re-render.
- "Add all discovered relays" button → `seedFromDiscovered()` → show
  "Added N relays" toast → re-render.
- Each active row has a remove button → `removeBroadcastRelay(url)`.
- Each skipped row shows the failure reason + timestamp, and an "Unskip"
  button → `unskipRelay(url)` → moves it back to active.
- `onBroadcastRelaysChanged` → re-render + update count badges.
- Listen for `ndkBroadcastRelaysUpdated` window event for cross-tab sync.

Styling: reuse the existing sidenav font-size (80%) and `var(--primary-color)`
tokens so it matches the "Show connection history" toggle above it. Skipped
relays render with reduced opacity to distinguish them from active relays.

### 7. Live cross-tab sync

The worker's kind 10088 subscription (step 1) updates `broadcastRelayUrls` and
broadcasts `broadcastRelaysUpdated` to all ports. `init-ndk.mjs` forwards this
to `window.dispatchEvent`. Any open `relays.html` re-renders; any pending
publish in any tab automatically uses the freshest relay set.

### 8. `post.html` footer — live broadcast progress display

**Remove the comments toggle button** from the footer center section
([`post.html:160-162`](www/post.html:160)):
```html
<!-- REMOVE: -->
<div id="divFooterCenter" class="divFooterBox">
  <button id="commentsToggleButton">Comments</button>
</div>
```
Also remove the comments toggle event listener at
[`post.html:813-817`](www/post.html:813) and the
`getCommentsVisible`/`setCommentsVisible` calls. Comments are no longer active
on this page.

**Replace with a broadcast progress display:**
```html
<div id="divFooterCenter" class="divFooterBox">
  <span id="spanBroadcastStatus"></span>
</div>
```

Wire it to the `ndkBroadcastProgress` window event:
```js
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');

window.addEventListener('ndkBroadcastProgress', (event) => {
  const d = event.detail;
  if (!d) return;

  if (d.phase === 'start') {
    spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
  } else if (d.phase === 'progress') {
    // Show the running total + the latest relay that responded.
    // Truncate the relay URL for the footer's limited width.
    const shortRelay = d.latestRelay
      ? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
      : '';
    spanBroadcastStatus.textContent =
      `📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
  } else if (d.phase === 'done') {
    spanBroadcastStatus.textContent =
      `✅ ${d.successful}/${d.total} relays` +
      (d.failed > 0 ? ` (${d.failed} failed)` : '');
    // Clear after 10 seconds so the footer doesn't show stale info forever.
    setTimeout(() => {
      if (spanBroadcastStatus.textContent.startsWith('✅')) {
        spanBroadcastStatus.textContent = '';
      }
    }, 10000);
  }
});
```

The display shows:
- **`start`**: "Broadcasting to N relays…"
- **`progress`**: "📡 42/150 relays · relay.example.com" — the count
  continuously increments as each relay responds, with the latest relay's
  hostname trailing.
- **`done`**: "✅ 148/150 relays (2 failed)" — final result, auto-clears after
  10s.

This is a **reusable function** — the `ndkBroadcastProgress` event fires for
every broadcast publish from any page. Other pages (feed.html, post-feed.html)
can add the same listener if they want to display broadcast progress in their
own footers later.

## Files touched

| File | Change |
|------|--------|
| [`www/ndk-worker.js`](www/ndk-worker.js) | +state (active + skipped), +hydration from kind 10088, +subscription, +`wantsBroadcastRelays`, +union in `handlePublish` with skip filtering, +live progress streaming via `relay:published`/`relay:publish:failed` events, +failure marking, +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` handlers, +debounced skip-mark save |
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` exports, +`ndkBroadcastRelaysUpdated` + `ndkBroadcastProgress` window event forwarding |
| [`www/js/broadcast-relays.mjs`](www/js/broadcast-relays.mjs) | **NEW** — load/save/add/remove/seed/unskip/subscribe wrapper |
| [`www/relays.html`](www/relays.html) | +Broadcast Relays management UI in sidenav below "Show connection history" toggle (active list, skipped list, add, seed-from-discovered, unskip, save) + wiring |
| [`www/post.html`](www/post.html) | Remove comments toggle button + listener, add broadcast progress display in footer center section wired to `ndkBroadcastProgress` |

No changes to `post-composer.mjs` or any page that calls `publishEvent` — the
union is transparent and always-on at the worker layer.

## Kinds reference

- **10088** — Broadcast Relay List (NIP-51 private list, NIP-44 encrypted
  content, replaceable `d=""`). Defined by Amethyst's quartz.
  - `["r", url]` tags = active broadcast relays (read by both our client and
    Amethyst).
  - `["x", url, reason, timestamp]` tags = skip-marked relays (read only by
    our client; Amethyst's `RelayTag` ignores non-`r` tags).
- **10002** — NIP-65 Relay List (unchanged; still the user's standard
  outbox/inbox).
- **1, 6, 7, 30023, 30315, 31123, 31124** — public event kinds that get the
  broadcast union.

## Testing

1. Open `relays.html`, add 3-5 broadcast relays manually, click Save.
2. Verify the kind 10088 event appears in the event-management page (kind
   10088 row) and that its content is NIP-44-encrypted.
3. Click "Add all discovered relays" → confirm N relays added from follows'
   kind 10002 lists.
4. Post a kind 1 from `post.html`.
5. **Footer progress display**: watch the footer center section — it should
   show "Broadcasting to N relays…", then continuously update
   "📡 42/150 relays · relay.example.com" as each relay responds, then settle
   on "✅ 148/150 relays (2 failed)" and auto-clear after 10s.
6. Check the worker console log: `[Worker] Broadcasting to N relays (X
   broadcast + Y outbox, Z skipped)`.
7. Confirm the post lands on the broadcast relays (query one via
   `publishRawEventToRelay`-style fetch or an external tool like `nak`).
8. Confirm a kind 4 DM or kind 30078 settings save does **not** go to
   broadcast relays (check the log lacks the "Broadcasting to" line, and the
   footer shows no broadcast progress).
9. Confirm the old "Comments" toggle button is gone from the `post.html`
   footer and no longer appears.
10. **Skip-mark test**: add a relay that will reject the publish (e.g. a
    private/auth-required relay). Post a kind 1. Confirm:
    - The publish log shows the relay in `failed`.
    - The worker logs `Skip-marked N failed broadcast relays`.
    - The kind 10088 event is republished with an `["x", url, ...]` tag.
    - In the `relays.html` sidenav, the relay appears in the "Skipped" section
      with the failure reason.
    - A subsequent kind 1 publish does **not** attempt that relay (count in
      log drops by 1).
11. **Unskip test**: click "Unskip" on a skipped relay → confirm it moves back
    to active and is included in the next publish.
12. **Amethyst coexistence test**: if Amethyst is available, edit the
    broadcast relay list in Amethyst → confirm our client's `relays.html`
    sidenav updates live (kind 10088 subscription). Then edit in our client →
    confirm Amethyst still shows its `r`-tag relays (our `x` tags don't
    interfere).
13. Open a second tab, edit the broadcast list in the sidenav, confirm the
    first tab's `relays.html` sidenav updates live.
