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)
- Always-on union — broadcast relays are automatically unioned into every public publish. No per-post toggle. (Matches Amethyst's default.)
- Manage on
relays.html— a new "Broadcast Relays" section alongside "Discovered Relays" for add/remove/save. - One-click seed from discovered relays — a button on
relays.htmlbulk- adds the discovered-relays URLs (from follows' kind 10002) to the 10088 list. User stays in control of when it happens. - 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.ktdefines kind 10088 — a NIP-51PrivateTagArrayEvent. Relay URLs live as["r", url]tags inside a NIP-44-encryptedcontentblob (private list), with an optional public-tag variant. It is replaceable per pubkey (d = "").AccountOutboxRelayState.ktunions the broadcast set into the outbox:nip65Outbox + privateOutBox + localRelays + broadcastRelays.Account.kt:1266gates inclusion withwantsBroadcastRelays(event)— public posts/reactions/ reposts get the broadcast set; drafts, app settings, bookmarks, channels with their own relays, and DMs do not.Account.kt:1715sendLiterallyEverywhere()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→ workerhandlePublish→ndkEvent.publish()with no relaySet → NDK outbox routing sends only to the user's kind 10002 write relays.publishRawEventToRelay→ workerhandlePublishRawToRelayalready demonstrates the exact NDK primitive we need:NDK'sNDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk) await ndkEvent.publish(targetRelaySet)fromRelayUrls(seendk-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.mjsestablishes the NIP-44-encrypted-private-list pattern we will mirror:configureMuteList({ nip44Encrypt, nip44Decrypt, publishEvent, getPubkey }).- The post composer (
post-composer.mjs:740) callsonSubmit(text, {attachments})→ pages callpublishEvent({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
randxtags from kind 10088. rtags = active broadcast relays (unioned into publishes).xtags = skip-marked relays (kept in the list for visibility, but excluded from the publish relay set).- When Amethyst updates the event, it only touches
rtags — ourxtags survive untouched. - When our client updates the event, we preserve all existing
rtags (so we don't clobber Amethyst's list) and only add/removextags. - 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:
- Call the existing
getDiscoveredRelaysworker handler to get the cached list of{url, servingPubkeys, ...}objects. - Extract just the URLs.
- Union them with the existing
r-tag broadcast relays (skip any already present, skip any in thex-tag skip-mark set). - 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:Reuse the existingconst 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] → skippedRelayUrlsnip44Decryptplumbing 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
broadcastRelaysUpdatedmessage to all ports (mirrors the existingrelay-types-updatedpattern atndk-worker.js:5177). - Add a helper
getActiveBroadcastUrls()that returnsArray.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u))— this is whathandlePublishwill 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 existingrtags and allxtags 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 thex-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 byhandlePublishafter failure marking. Same save logic assaveBroadcastRelaysbut only updates thextags, preserving the currentrtags. Debounce with a 5s timer so multiple failures in one publish produce one republish.Reuse the existing
signEventWithMessageSignerand the existingnip44Encrypt/nip44Decrypthelpers 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:
-
broadcastRelaysUpdated→window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated', {detail}))(same pattern asndkRelayActivityatndk-worker.js:2673). -
broadcastProgress→window.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
ndkBroadcastRelaysUpdatedwindow 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):
<!-- 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'sRelayTagignores non-rtags).
- 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
- Open
relays.html, add 3-5 broadcast relays manually, click Save. - Verify the kind 10088 event appears in the event-management page (kind 10088 row) and that its content is NIP-44-encrypted.
- Click "Add all discovered relays" → confirm N relays added from follows' kind 10002 lists.
- Post a kind 1 from
post.html. - 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.
- Check the worker console log:
[Worker] Broadcasting to N relays (X broadcast + Y outbox, Z skipped). - Confirm the post lands on the broadcast relays (query one via
publishRawEventToRelay-style fetch or an external tool likenak). - 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).
- Confirm the old "Comments" toggle button is gone from the
post.htmlfooter and no longer appears. - 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.htmlsidenav, 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).
- The publish log shows the relay in
- Unskip test: click "Unskip" on a skipped relay → confirm it moves back to active and is included in the next publish.
- Amethyst coexistence test: if Amethyst is available, edit the
broadcast relay list in Amethyst → confirm our client's
relays.htmlsidenav updates live (kind 10088 subscription). Then edit in our client → confirm Amethyst still shows itsr-tag relays (ourxtags don't interfere). - Open a second tab, edit the broadcast list in the sidenav, confirm the
first tab's
relays.htmlsidenav updates live.