Files
client/plans/broadcast-relays-feature.md

30 KiB

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 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 unions the broadcast set into the outbox: nip65Outbox + privateOutBox + localRelays + broadcastRelays.
  • 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 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 → worker handlePublishndkEvent.publish() with no relaySet → NDK outbox routing sends only to the user's kind 10002 write relays.
  • publishRawEventToRelay → worker handlePublishRawToRelay already demonstrates the exact NDK primitive we need:
    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 establishes the NIP-44-encrypted-private-list pattern we will mirror: configureMuteList({ nip44Encrypt, nip44Decrypt, publishEvent, getPubkey }).
  • The post composer (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 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 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

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:
    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:
    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):

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():

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 already computes relayResults.failed (connected relays that didn't accept the event). Extend it to persist skip-marks for broadcast relays that failed:

// 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:

    {
      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:

    // 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":

    // 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):

    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:

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. broadcastRelaysUpdatedwindow.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated', {detail})) (same pattern as ndkRelayActivity at ndk-worker.js:2673).

  2. broadcastProgresswindow.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {detail})) so pages can show live publish progress. The detail payload:

    {
      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:

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.

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

<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.

Remove the comments toggle button from the footer center section (post.html:160-162):

<!-- REMOVE: -->
<div id="divFooterCenter" class="divFooterBox">
  <button id="commentsToggleButton">Comments</button>
</div>

Also remove the comments toggle event listener at post.html:813-817 and the getCommentsVisible/setCommentsVisible calls. Comments are no longer active on this page.

Replace with a broadcast progress display:

<div id="divFooterCenter" class="divFooterBox">
  <span id="spanBroadcastStatus"></span>
</div>

Wire it to the ndkBroadcastProgress window event:

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 +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 +getBroadcastRelays/saveBroadcastRelays/seedBroadcastRelays/unskipBroadcastRelay exports, +ndkBroadcastRelaysUpdated + ndkBroadcastProgress window event forwarding
www/js/broadcast-relays.mjs NEW — load/save/add/remove/seed/unskip/subscribe wrapper
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 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.