Compare commits

..

22 Commits

Author SHA1 Message Date
Laan Tungir
9b746838c3 Fix batch info in footer: include batchCurrent/batchTotal in per-relay progress events, set currentBatch before each batch starts 2026-06-30 10:55:04 -04:00
Laan Tungir
978e5029f3 Skip-mark timed-out broadcast relays too (not just explicit rejections) — reason distinguishes 'publish-timeout' from 'publish-rejected' 2026-06-30 10:42:12 -04:00
Laan Tungir
011e4c303c Show batch progress in footer: '📡 50/630 relays · 2/25 batches · relay.example.com' 2026-06-30 10:36:42 -04:00
Laan Tungir
fa019fe9a1 Send publish response after outbox relays complete, then run broadcast batches in background — composer clears immediately while broadcast continues 2026-06-30 10:33:14 -04:00
Laan Tungir
0f31e1c301 Batched broadcast publishing: split 600+ relays into batches of 25, publish sequentially to avoid browser WebSocket connection limits. 10s per-relay timeout per batch. 2026-06-30 10:21:55 -04:00
Laan Tungir
c9a20e63b5 Keep broadcast result in footer until next publish overwrites it (remove 10s auto-clear) 2026-06-30 10:13:46 -04:00
Laan Tungir
79e38b8f79 Experiment: increase per-relay broadcast timeout to 60s to test if NDK can handle 600 relays with longer connection time 2026-06-30 10:11:08 -04:00
Laan Tungir
7cfd43b91b Set per-relay broadcast timeout to 5s, overall deadline to totalRelays*5s (min 30s). NDK runs relays in parallel so 600 relays finish in ~5s not 3000s 2026-06-30 10:06:37 -04:00
Laan Tungir
777ab312a5 Fix broadcast timeout: increase connection timeout to 15s for temporary relays, race publish against 45s overall deadline so one slow relay doesn't block 2026-06-30 10:02:00 -04:00
Laan Tungir
2763dd9d6a Add relay URL hover tooltip on post time: shows 'Published to N relays:' with URL list when hovering over the relay count 2026-06-30 09:37:36 -04:00
Laan Tungir
a20b28cd21 Fix relay count display: emit broadcastProgress done event for all publishes (not just broadcasts), add missing eventId/eventKind to broadcast done event 2026-06-30 09:33:08 -04:00
Laan Tungir
456f0e1f2e Add relay count to post time display: shows '21r - 1h' format on each post card, tracking successful relay count from broadcastProgress events 2026-06-30 09:28:38 -04:00
Laan Tungir
4abb4bf33c Add 10-sat fee-reserve buffer to handleWalletPayInvoice melt to fix 'not enough inputs provided for melt' errors 2026-06-30 09:19:30 -04:00
Laan Tungir
7dcfc7c3e3 Fix broadcast relay timeout: use 30s timeout for large broadcasts, distinguish timeouts from explicit rejections, only skip-mark explicit rejections, use live-tracked sets for results 2026-06-30 09:15:02 -04:00
Laan Tungir
07f5f39f35 Add per-mint tracing logs to handleWalletPayInvoice for lightning zap diagnostics 2026-06-30 07:59:52 -04:00
Laan Tungir
10ddda633c Add broadcast relays feature: kind 10088 list with always-on union, persistent skip-marks, live progress in post.html footer, sidenav management UI on relays.html 2026-06-30 07:47:22 -04:00
Laan Tungir
c55a5ebfd8 Self-host GPT-2 model files to fix CORS over Tor/onion origins 2026-06-28 20:46:57 -04:00
Laan Tungir
b6e945f8de Use locally-hosted GPT-2 model to fix CORS over Tor/onion origins 2026-06-28 20:45:25 -04:00
Laan Tungir
3ec585273f Change 'post on Primal' to 'post on Nostr' in inspiration FAQ 2026-06-28 20:32:50 -04:00
Laan Tungir
0e8229e13e Update inspiration FAQ to reference waxwing's Primal post and the Meteor paper 2026-06-28 20:30:26 -04:00
Laan Tungir
230bd70e07 Change all muted/dimmed text on stego page to regular primary color 2026-06-28 20:27:13 -04:00
Laan Tungir
c36cb4a0fe Replace simple example FAQ with detailed toy-model walkthrough from llm_steganography_explained.md 2026-06-28 20:25:51 -04:00
11 changed files with 2440 additions and 97 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,6 @@
# GPT-2 model files (271MB, too large for git — uploaded directly to server)
www/models/
hamburger_morphing/
client/
ndk/

View File

@@ -0,0 +1,122 @@
# Broadcast Relay Batching Fix
## Problem
With 600+ broadcast relays, NDK's `fromRelayUrls` creates 630 temporary relays
and adds them all to the pool simultaneously. `NDKRelaySet.publish()` then fires
all 630 relay publish operations in parallel via `Promise.all`. Each relay that
isn't connected calls `relay.connect()` which opens a WebSocket.
Browsers limit simultaneous WebSocket connections (practically ~50-100 at a
time before connections queue up). With 630 simultaneous connection attempts,
most relays never get a connection slot before the per-relay timeout expires.
Result: only ~13/630 relays succeed.
## Solution: Chunked publishing
Instead of creating one giant `NDKRelaySet` with all 630 relays and calling
`publish()` once, we split the relay URLs into **batches of 50** and publish
to each batch sequentially. Each batch gets its own `NDKRelaySet.fromRelayUrls()`
call, its own `publish()`, and its own set of live progress listeners.
```mermaid
flowchart TD
A[630 relay URLs] --> B[Split into batches of 50]
B --> C[Batch 1: 50 relays]
C --> D[publish to batch 1]
D --> E{Batch 1 done?}
E -->|yes| F[Batch 2: 50 relays]
F --> G[publish to batch 2]
G --> H{Batch 2 done?}
H -->|yes| I[... continue ...]
I --> J[Batch 13: 30 relays]
J --> K[publish to batch 13]
K --> L{All batches done?}
L -->|yes| M[Final done event with total count]
```
### Key design points
1. **Batch size: 50 relays** — small enough to stay within browser WebSocket
limits, large enough to finish in a reasonable time. Each batch takes ~5-10s.
2. **Sequential batches** — wait for each batch to complete before starting the
next. This keeps the total simultaneous connections at ~50, not 630.
3. **Live progress across batches** — the `publishedSoFar` and `failedSoFar`
sets accumulate across all batches. The `broadcastProgress` events stream
continuously, so the footer shows the count climbing from 0 to 630 across
all batches.
4. **Per-relay timeout: 10s per batch** — each relay in a batch gets 10s to
connect + publish. Since only 50 are competing for connections (not 630),
this is plenty of time.
5. **Overall deadline: removed** — since batches are sequential and each has
its own per-relay timeout, the overall time is naturally bounded at
`numBatches × perBatchTimeout`. No need for a separate overall deadline.
6. **Connection timeout: 10s** — set on each relay in the batch, matching the
publish timeout.
## Implementation (in `www/ndk-worker.js` `handlePublish`)
Replace the current single `NDKRelaySet.fromRelayUrls(allUrls, ndk)` + single
`publish()` with a batched loop:
```js
const BATCH_SIZE = 50;
const PER_RELAY_TIMEOUT_MS = 10000;
if (isBroadcast) {
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
const batches = [];
for (let i = 0; i < allUrls.length; i += BATCH_SIZE) {
batches.push(allUrls.slice(i, i + BATCH_SIZE));
}
console.log(`[Worker] Broadcasting to ${allUrls.length} relays in ${batches.length} batches of ${BATCH_SIZE}`);
// Emit start event
broadcast({ type: 'broadcastProgress', sessionId, eventId, eventKind, phase: 'start', total: allUrls.length, successful: 0, failed: 0, latestRelay: null });
// Attach listeners once — they accumulate across all batches
ndkEvent.on('relay:published', (relay) => { ... publishedSoFar.add ... });
ndkEvent.on('relay:publish:failed', (relay, err) => { ... failedSoFar.add ... });
// Publish to each batch sequentially
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
// Set connection timeout on each relay in this batch
for (const relay of batchSet.relays) {
try { relay.connectionTimeout = PER_RELAY_TIMEOUT_MS; } catch (_) {}
}
try {
await ndkEvent.publish(batchSet, PER_RELAY_TIMEOUT_MS, 1);
} catch (e) {
// Expected — some relays in this batch may fail
}
console.log(`[Worker] Batch ${i+1}/${batches.length} done: ${publishedSoFar.size}/${allUrls.length} total succeeded`);
}
// Emit final done event
broadcast({ type: 'broadcastProgress', sessionId, eventId, eventKind, phase: 'done', total: allUrls.length, successful: publishedSoFar.size, failed: failedSoFar.size + timedOutSoFar.size, relayUrls: Array.from(publishedSoFar) });
// Clean up listeners
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
}
```
## Files touched
| File | Change |
|------|--------|
| [`www/ndk-worker.js`](www/ndk-worker.js) | Replace single publish with batched loop in `handlePublish` |
## Testing
1. Post with a user that has 600+ broadcast relays
2. Watch the footer: count should climb steadily as batches complete
3. Final count should be much higher than 13 (ideally 500+)
4. Each batch of 50 should take ~5-10 seconds
5. Total time for 630 relays: ~13 batches × ~10s = ~130 seconds

View File

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

190
www/js/broadcast-relays.mjs Normal file
View File

@@ -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<url, { reason, timestamp }> 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');

View File

@@ -373,6 +373,30 @@ 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,
relayUrls: message.relayUrls || [],
batchCurrent: message.batchCurrent,
batchTotal: message.batchTotal,
}
}));
} else if (message.type === 'startupStatus') {
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
} else if (message.type === 'relayEvent') {
@@ -725,6 +749,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

View File

@@ -915,16 +915,78 @@ export function formatTimeAgo(timestamp) {
}
// Store timestamps for real-time updates
const timeAgoElements = new Map(); // element -> timestamp
const timeAgoElements = new Map(); // element -> { timestamp, eventId }
// Track relay publish info per event ID from broadcastProgress events.
// Keyed by event ID → { count, urls } where urls is an array of relay URLs.
const relayInfoByEventId = new Map();
// Listen for broadcast progress events to capture final relay counts and URLs.
// The worker emits ndkBroadcastProgress with phase 'done' containing the
// total successful count and the list of relay URLs. We store it so post
// cards can show "21r - 1h" with a hover tooltip listing the relays.
if (typeof window !== 'undefined') {
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || d.phase !== 'done') return;
const eventId = d.eventId;
if (!eventId) return;
relayInfoByEventId.set(eventId, {
count: d.successful || 0,
urls: Array.isArray(d.relayUrls) ? d.relayUrls : [],
});
// Update any already-rendered time elements for this event.
timeAgoElements.forEach((info, element) => {
if (info.eventId === eventId && element.isConnected) {
updateTimeAgoElement(element, info);
}
});
});
}
/**
* Build the hover tooltip text for a relay list.
* Shows "Published to N relays:" followed by the URL list, truncated.
* @param {number} count - Number of successful relays
* @param {string[]} urls - Array of relay URLs
* @returns {string}
*/
function buildRelayTooltip(count, urls) {
if (!count || count === 0) return '';
const header = `Published to ${count} relay${count === 1 ? '' : 's'}:`;
if (!urls || urls.length === 0) return header;
// Show up to 50 relay URLs in the tooltip; beyond that, show a summary.
if (urls.length <= 50) {
return header + '\n' + urls.join('\n');
}
return header + '\n' + urls.slice(0, 50).join('\n') + `\n… and ${urls.length - 50} more`;
}
/**
* Update a time-ago element's text and tooltip from its stored info.
* @param {HTMLElement} element - The element to update
* @param {{timestamp: number, eventId: string}} info - Stored info
*/
function updateTimeAgoElement(element, info) {
const timeStr = formatTimeAgo(info.timestamp);
if (info.eventId && relayInfoByEventId.has(info.eventId)) {
const relayInfo = relayInfoByEventId.get(info.eventId);
element.textContent = `${relayInfo.count}r - ${timeStr}`;
element.title = buildRelayTooltip(relayInfo.count, relayInfo.urls);
} else {
element.textContent = timeStr;
}
}
/**
* Register an element for time updates
* @param {HTMLElement} element - The element to update
* @param {number} timestamp - Unix timestamp in seconds
* @param {string} [eventId] - Optional event ID for relay count display
*/
export function registerTimeAgo(element, timestamp) {
timeAgoElements.set(element, timestamp);
element.textContent = formatTimeAgo(timestamp);
export function registerTimeAgo(element, timestamp, eventId) {
timeAgoElements.set(element, { timestamp, eventId });
updateTimeAgoElement(element, { timestamp, eventId });
}
/**
@@ -932,9 +994,9 @@ export function registerTimeAgo(element, timestamp) {
* Call this periodically (e.g., every 30 seconds)
*/
export function updateTimeAgos() {
timeAgoElements.forEach((timestamp, element) => {
timeAgoElements.forEach((info, element) => {
if (element.isConnected) {
element.textContent = formatTimeAgo(timestamp);
updateTimeAgoElement(element, info);
} else {
// Clean up detached elements
timeAgoElements.delete(element);
@@ -1085,10 +1147,12 @@ export function renderFooterRow(eventId, eventData, options = {}) {
container.appendChild(nutzapItem);
container.appendChild(midControls);
// Time as the final item in the same row
// Time as the final item in the same row.
// Pass eventId so registerTimeAgo can prepend the relay count
// (e.g., "21r - 1h") when a broadcastProgress 'done' event arrives.
const timeEl = document.createElement('span');
timeEl.className = 'divPostTime';
registerTimeAgo(timeEl, eventData.created_at);
registerTimeAgo(timeEl, eventData.created_at, eventId);
container.appendChild(timeEl);
footerRow.appendChild(container);

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.59",
"VERSION_NUMBER": "0.7.59",
"BUILD_DATE": "2026-06-29T00:23:01.531Z"
"VERSION": "v0.7.81",
"VERSION_NUMBER": "0.7.81",
"BUILD_DATE": "2026-06-30T14:55:04.841Z"
}

View File

@@ -91,7 +91,7 @@
}
#stegoWrap .subtitle {
color: var(--muted-color);
color: var(--primary-color);
margin: 0;
font-size: 14px;
}
@@ -112,7 +112,7 @@
}
.stegoHint {
color: var(--muted-color);
color: var(--primary-color);
font-size: 13px;
margin: 0 0 12px;
}
@@ -126,7 +126,7 @@
.stegoFormRow label {
font-size: 13px;
color: var(--muted-color);
color: var(--primary-color);
margin-bottom: 4px;
}
@@ -158,7 +158,7 @@
}
.stegoStatus {
color: var(--muted-color);
color: var(--primary-color);
font-size: 13px;
margin: 8px 0;
min-height: 1.2em;
@@ -166,7 +166,7 @@
.stegoInfo {
font-size: 13px;
color: var(--muted-color);
color: var(--primary-color);
margin: 8px 0 0;
white-space: pre-wrap;
}
@@ -200,7 +200,7 @@
.stegoProgressFiles {
margin-top: 8px;
font-size: 12px;
color: var(--muted-color);
color: var(--primary-color);
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
max-height: 120px;
overflow-y: auto;
@@ -333,9 +333,57 @@
margin-bottom: 4px;
}
/* ---------- Example walkthrough ---------- */
.stegoExampleH3 {
font-size: 15px;
font-weight: 700;
color: var(--accent-color);
margin: 16px 0 8px 0;
}
.stegoExampleH4 {
font-size: 14px;
font-weight: 600;
color: var(--primary-color);
margin: 14px 0 6px 0;
}
.stegoExampleOutput {
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
background: var(--background-color);
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--border-color);
color: var(--accent-color);
font-size: 13px;
margin: 8px 0;
}
.stegoTableWrap {
overflow-x: auto;
margin: 10px 0;
}
.stegoTable {
width: 100%;
border-collapse: collapse;
font-size: 12px;
font-family: var(--font-mono, "SF Mono", "Fira Code", "Consolas", monospace);
}
.stegoTable th,
.stegoTable td {
border: 1px solid var(--border-color);
padding: 4px 8px;
text-align: left;
color: var(--primary-color);
}
.stegoTable th {
background: var(--background-color);
font-weight: 600;
}
.stegoTable tr:nth-child(even) td {
background: var(--background-color);
}
.stegoFooter {
text-align: center;
color: var(--muted-color);
color: var(--primary-color);
font-size: 12px;
margin-top: 24px;
}
@@ -509,41 +557,183 @@
</button>
<div class="stegoCollapsibleContent stegoHidden">
<p>
Suppose the context is <code>"I like to eat"</code> and your secret
message is <code>"HI"</code>. The letter <code>H</code> is byte
<code>0x48</code> = bits <code>01001000</code>, and <code>I</code> is
<code>0x49</code> = bits <code>01001001</code>. So the encoder needs to
hide 16 bits total.
To make this tangible, let's use a <strong>toy model with a small
vocabulary of 11 words</strong> so you can see every number. The real
scheme works identically, just with vocabularies of ~50,000 tokens and
floating-point probabilities.
</p>
<h3 class="stegoExampleH3">Setup</h3>
<p>
<strong>The model:</strong> A tiny "LLM" with a fixed vocabulary of 11
words: <code>{apple, date, banana, cherry, pie, juice, tart, sauce, for, with, and}</code>.
At each step, the model assigns a probability to all 11 words based on
the context. The probabilities change as the context grows, and they
always sum to 1.0.
</p>
<p>
For the <strong>first bit (0)</strong>, GPT-2 looks at
<code>"I like to eat"</code> and ranks every possible next token by
probability. It splits that ranked list at the 50% cumulative-probability
mark into a "high-prob" half and a "low-prob" half. Since the bit is
<code>0</code>, the encoder samples a token from the <em>high-prob</em>
half — say <code>" pizza"</code>. The cover text is now
<code>"I like to eat pizza"</code>.
<strong>Shared context/prompt:</strong> <code>"I like to eat"</code>
both Alice and Bob have this. It is not secret, just shared.
</p>
<p>
For the <strong>second bit (1)</strong>, GPT-2 now looks at
<code>"I like to eat pizza"</code> and produces a fresh distribution.
The bit is <code>1</code>, so the encoder samples from the
<em>low-prob</em> half — maybe <code>" and"</code>. Cover text:
<code>"I like to eat pizza and"</code>.
<strong>Shared secret key:</strong> Used to seed a PRNG, producing the
random stream: <code>0.15, 0.62, 0.40, ...</code>
</p>
<p>
This continues for all 16 bits, then a few padding tokens are added so
the text ends naturally. The final cover text might read something like:
</p>
<p style="font-family: var(--font-mono, monospace); background: var(--background-color); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--border-color);">
"I like to eat pizza and pasta with my friends on weekends."
<strong>Secret message:</strong> The bits <code>0 1 1</code> (3 bits).
</p>
<h3 class="stegoExampleH3">Encoding (Alice's Side)</h3>
<h4 class="stegoExampleH4">Step 1: Alice runs the model</h4>
<p>
To anyone else, that's just a normal sentence. But the decoder — who
knows the same context, key, and model — re-runs GPT-2 at each step,
checks which half each token fell into, and recovers the bits
<code>01001000 01001001</code><code>"HI"</code>.
Alice feeds <code>"I like to eat"</code> into the model. After "I like
to eat", fruits are the most natural continuation, so they get the
highest probabilities:
</p>
<div class="stegoTableWrap">
<table class="stegoTable">
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
<tbody>
<tr><td>apple</td><td>0.20</td><td>[0.00, 0.20)</td></tr>
<tr><td>date</td><td>0.15</td><td>[0.20, 0.35)</td></tr>
<tr><td>banana</td><td>0.10</td><td>[0.35, 0.45)</td></tr>
<tr><td>cherry</td><td>0.05</td><td>[0.45, 0.50)</td></tr>
<tr><td>pie</td><td>0.20</td><td>[0.50, 0.70)</td></tr>
<tr><td>juice</td><td>0.12</td><td>[0.70, 0.82)</td></tr>
<tr><td>tart</td><td>0.08</td><td>[0.82, 0.90)</td></tr>
<tr><td>sauce</td><td>0.05</td><td>[0.90, 0.95)</td></tr>
<tr><td>for</td><td>0.03</td><td>[0.95, 0.98)</td></tr>
<tr><td>with</td><td>0.015</td><td>[0.98, 0.995)</td></tr>
<tr><td>and</td><td>0.005</td><td>[0.995, 1.00)</td></tr>
</tbody>
</table>
</div>
<p>The probabilities sum to 1.0, and the intervals partition [0, 1).</p>
<h4 class="stegoExampleH4">Step 2: Alice encodes her first secret bit</h4>
<p>
Alice's first secret bit is <code>0</code> → "look in the <strong>first
half</strong> [0.00, 0.50)." The words in that half are apple, date,
banana, and cherry. Alice uses her next random number, <code>0.15</code>,
to pick within [0.00, 0.50). The value <code>0.15</code> falls in
[0.00, 0.20), which is <strong>apple</strong>.
</p>
<p class="stegoExampleOutput">Alice outputs: "apple" → text so far: "I like to eat apple"</p>
<h4 class="stegoExampleH4">Step 3: Alice runs the model again</h4>
<p>
Context is now <code>"I like to eat apple"</code>. After "apple", food
preparations like pie and tart become more likely:
</p>
<div class="stegoTableWrap">
<table class="stegoTable">
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
<tbody>
<tr><td>apple</td><td>0.15</td><td>[0.00, 0.15)</td></tr>
<tr><td>date</td><td>0.10</td><td>[0.15, 0.25)</td></tr>
<tr><td>banana</td><td>0.08</td><td>[0.25, 0.33)</td></tr>
<tr><td>cherry</td><td>0.07</td><td>[0.33, 0.40)</td></tr>
<tr><td>for</td><td>0.06</td><td>[0.40, 0.46)</td></tr>
<tr><td>with</td><td>0.04</td><td>[0.46, 0.50)</td></tr>
<tr><td>pie</td><td>0.20</td><td>[0.50, 0.70)</td></tr>
<tr><td>tart</td><td>0.16</td><td>[0.70, 0.86)</td></tr>
<tr><td>juice</td><td>0.08</td><td>[0.86, 0.94)</td></tr>
<tr><td>sauce</td><td>0.04</td><td>[0.94, 0.98)</td></tr>
<tr><td>and</td><td>0.02</td><td>[0.98, 1.00)</td></tr>
</tbody>
</table>
</div>
<p>
Alice's next bit is <code>1</code> → "look in the <strong>second half</strong>
[0.50, 1.00)." Random number <code>0.62</code> → scaled:
<code>0.50 + 0.62 × 0.50 = 0.81</code>, which falls in [0.70, 0.86) →
<strong>tart</strong>.
</p>
<p class="stegoExampleOutput">Alice outputs: "tart" → text so far: "I like to eat apple tart"</p>
<h4 class="stegoExampleH4">Step 4: Alice runs the model again</h4>
<p>
Context: <code>"I like to eat apple tart"</code>. After "apple tart",
connectors like "with" and "and" become most likely:
</p>
<div class="stegoTableWrap">
<table class="stegoTable">
<thead><tr><th>Word</th><th>Probability</th><th>Interval</th></tr></thead>
<tbody>
<tr><td>apple</td><td>0.10</td><td>[0.00, 0.10)</td></tr>
<tr><td>date</td><td>0.08</td><td>[0.10, 0.18)</td></tr>
<tr><td>banana</td><td>0.07</td><td>[0.18, 0.25)</td></tr>
<tr><td>cherry</td><td>0.06</td><td>[0.25, 0.31)</td></tr>
<tr><td>pie</td><td>0.08</td><td>[0.31, 0.39)</td></tr>
<tr><td>juice</td><td>0.06</td><td>[0.39, 0.45)</td></tr>
<tr><td>for</td><td>0.05</td><td>[0.45, 0.50)</td></tr>
<tr><td>tart</td><td>0.06</td><td>[0.50, 0.56)</td></tr>
<tr><td>sauce</td><td>0.04</td><td>[0.56, 0.60)</td></tr>
<tr><td>with</td><td>0.18</td><td>[0.60, 0.78)</td></tr>
<tr><td>and</td><td>0.22</td><td>[0.78, 1.00)</td></tr>
</tbody>
</table>
</div>
<p>
Alice's next bit is <code>1</code> → second half [0.50, 1.00). Random
number <code>0.40</code> → scaled: <code>0.50 + 0.40 × 0.50 = 0.70</code>,
falls in [0.60, 0.78) → <strong>with</strong>.
</p>
<p class="stegoExampleOutput">Alice outputs: "with" → text so far: "I like to eat apple tart with"</p>
<h4 class="stegoExampleH4">Alice is done</h4>
<p>
Alice sends Bob the text: <strong>"I like to eat apple tart with"</strong>
(plus whatever padding she wants to make it look like a complete
sentence). To any observer, this looks like someone generated a
sentence about food. Nothing suspicious.
</p>
<h3 class="stegoExampleH3">Decoding (Bob's Side)</h3>
<p>
Bob has the received text, the same model, the same shared context
<code>"I like to eat"</code>, and the same shared key (so the same
random stream: <code>0.15, 0.62, 0.40, ...</code>).
</p>
<h4 class="stegoExampleH4">Step D1: Bob runs the model</h4>
<p>
Bob feeds <code>"I like to eat"</code> into the model and gets the
<strong>same</strong> distribution Alice got. He sees Alice chose
<strong>apple</strong>, interval [0.00, 0.20). "Which half?" First half
[0.00, 0.50) → <strong>bit <code>0</code></strong>
</p>
<h4 class="stegoExampleH4">Step D2: Bob runs the model again</h4>
<p>
Context: <code>"I like to eat apple"</code>. Same distribution as Alice's
Step 3. Bob sees Alice chose <strong>tart</strong>, interval [0.70, 0.86).
"Which half?" Second half [0.50, 1.00) → <strong>bit <code>1</code></strong>
</p>
<h4 class="stegoExampleH4">Step D3: Bob runs the model again</h4>
<p>
Context: <code>"I like to eat apple tart"</code>. Same distribution as
Alice's Step 4. Bob sees Alice chose <strong>with</strong>, interval
[0.60, 0.78). "Which half?" Second half [0.50, 1.00) →
<strong>bit <code>1</code></strong>
</p>
<h3 class="stegoExampleH3">Result</h3>
<p>
Bob has recovered: <code>0 1 1</code> — exactly the message Alice sent.
</p>
<h3 class="stegoExampleH3">Key Points</h3>
<ol>
<li><strong>The LLM never saw the secret message.</strong> The secret bits controlled which word was picked from the distribution. The LLM just produced distributions.</li>
<li><strong>The output looks natural.</strong> "I like to eat apple tart with" is a perfectly normal sentence. A censor seeing this has no reason to be suspicious.</li>
<li><strong>Both sides run the same model with the same context.</strong> This is why they get the same distributions. If Bob had a different model or different context, decoding would fail.</li>
<li><strong>The shared key provides the random stream.</strong> Without it, a censor could run the same model and try to decode. With the key, the censor can't reproduce the random choices.</li>
<li><strong>Each token encodes roughly 1 bit</strong> in this toy example (2-way split). With arithmetic coding over a 50,000-word vocabulary, you can encode ~24 bits per token.</li>
<li><strong>The "half" splitting is the simplified version.</strong> The real scheme (from the <a href="https://eprint.iacr.org/2021/686" target="_blank" rel="noopener">Meteor paper</a>) uses full arithmetic coding, which is more efficient. But the principle is the same: secret bits steer selection within the model's probability distribution.</li>
</ol>
</div>
</div>
@@ -554,35 +744,38 @@
</button>
<div class="stegoCollapsibleContent stegoHidden">
<p>
This project is inspired by research into <strong>linguistic
steganography</strong> — the art of hiding information within natural
language. Traditional steganography hides data in images, audio, or
file metadata, but text-based steganography is harder because text is
compact and every character is visible.
The direct inspiration for this project is a
<a href="https://primal.net/e/nevent1qqstxmgsd0egtq57gj8mghckthsv79dhegh7xh97hwtwve6l56g4s9sqtjd7k" target="_blank" rel="noopener">post by waxwing on Nostr</a>
that walks through the core idea of using a language model's
next-token probability distribution as a steganographic carrier
channel. The post explains how secret bits can steer token selection
while preserving the model's output distribution, making the
resulting cover text statistically indistinguishable from normal
model output.
</p>
<p>
The breakthrough idea is to use a <strong>language model's own
probability distribution</strong> as the carrier channel. Instead of
tweaking pixels or LSBs, you steer which token the model emits next,
encoding one secret bit per token. Key inspirations include:
That post references the academic paper that formalized this
approach:
</p>
<ul>
<li><strong>Entropy coding approaches</strong> (e.g. Cachin's
information-theoretic steganography) that treat the cover as a
probability distribution and map secret bits onto it.</li>
<li><strong>"Dissociated Press" / Markov-chain steganography</strong>
— early experiments that used statistical text generation to hide
messages, limited by the poor quality of pre-LLM language models.</li>
<li><strong>Modern LLM steganography research</strong> — papers that
use GPT-style models to achieve near-perfect cover text quality
while encoding multiple bits per token via arithmetic or
half-splitting codes.</li>
<li>
<strong><a href="https://eprint.iacr.org/2021/686" target="_blank" rel="noopener">Meteor: A Simple and Practical Steganographic Protocol for LLMs</a></strong>
— this paper introduces the entropy-coding mechanism that makes
the output distribution-preserving. Instead of a naive 2-way
split, the full scheme uses arithmetic coding over the model's
probability distribution, achieving roughly 24 bits per token
while remaining information-theoretically undetectable to a censor
who only observes the channel.
</li>
</ul>
<p>
The half-splitting scheme used here is one of the simplest robust
approaches: it guarantees exactly one bit per token, is symmetric
(encoder and decoder run the same logic), and produces text that's
indistinguishable from normal model output to a casual reader.
The half-splitting scheme implemented in this demo is a simplified
version of Meteor's approach: it guarantees exactly one bit per
token, is symmetric (encoder and decoder run the same logic), and
produces text that's indistinguishable from normal model output to
a casual reader. The full arithmetic-coding version from the paper
is more efficient but the core principle is identical — secret bits
steer selection within the model's probability distribution.
</p>
</div>
</div>
@@ -1349,8 +1542,12 @@
} from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
import { encode, decode } from "./js/stego.mjs";
// Use locally-hosted model files (./models/Xenova/gpt2/) so the page
// works over Tor/onion origins where HuggingFace CORS is blocked.
const MODEL_ID = "Xenova/gpt2";
env.allowLocalModels = false;
env.allowLocalModels = true;
env.localModelPath = "./models/";
env.allowRemoteModels = false;
// ---------------------------------------------------------------------------
// DOM references

View File

@@ -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<boolean>} 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<boolean>} 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 || []) {
@@ -4291,6 +4568,16 @@ async function handleWalletCreateDeposit(requestId, amount, mint, port) {
}
async function handleWalletPayInvoice(requestId, invoice, mint, port) {
const startedAt = Date.now();
const traceId = `walletPayInvoice:${requestId}`;
const log = (phase, extra = null) => {
if (extra === null) {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
} else {
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
}
};
try {
await ensureDirectWalletLoaded();
@@ -4303,6 +4590,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
log('mint-selection', {
requestedMint: requestedMint || null,
orderedMints: orderedMints.map((m) => ({ mint: m, proofBalance: getProofTotal(directProofStore[m]) }))
});
let payResult = null;
let usedMint = null;
let paidAmountSats = 0;
@@ -4311,7 +4603,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
for (const mintUrl of orderedMints) {
try {
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
const proofBalance = getProofTotal(proofs);
log('mint attempt:start', { mintUrl, proofBalance, proofCount: proofs.length });
if (proofs.length === 0) {
log('mint attempt:no-proofs', { mintUrl });
attemptErrors.push({ mint: mintUrl, message: 'No proofs available for mint' });
continue;
}
@@ -4320,44 +4616,60 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
const meltQuote = await wallet.createMeltQuote(pr);
const quoteAmount = Number(meltQuote?.amount || 0);
const quoteFeeReserve = Number(meltQuote?.fee_reserve || 0);
// The mint's quoted fee_reserve is an estimate; the actual melt fee can be
// slightly higher, causing "not enough inputs provided for melt" errors.
// Add a safety buffer — any overpayment is returned as change proofs.
const FEE_RESERVE_BUFFER_SATS = 10;
const required = quoteAmount + quoteFeeReserve;
const sendAmount = required + FEE_RESERVE_BUFFER_SATS;
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required, sendAmount });
if (!Number.isFinite(required) || required <= 0) {
log('mint attempt:invalid-quote', { mintUrl, required });
attemptErrors.push({ mint: mintUrl, message: 'Invalid melt quote amount' });
continue;
}
const proofBalance = getProofTotal(proofs);
if (proofBalance < required) {
if (proofBalance < sendAmount) {
log('mint attempt:insufficient', { mintUrl, proofBalance, sendAmount });
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
continue;
}
const sendResult = await wallet.send(required, proofs);
const sendResult = await wallet.send(sendAmount, proofs);
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length, sendAmount });
const meltResult = await wallet.meltProofs(meltQuote, sendProofs);
const change = Array.isArray(meltResult?.change) ? meltResult.change : [];
upsertMintProofs(mintUrl, [...keep, ...change]);
const changeAmount = getProofTotal(change);
const spentAmount = Math.max(0, required - changeAmount);
const spentAmount = Math.max(0, sendAmount - changeAmount);
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
? quoteAmount
: spentAmount;
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, spentAmount, preimage: meltResult?.preimage || null });
usedMint = mintUrl;
payResult = meltResult;
break;
} catch (error) {
log('mint attempt:error', { mintUrl, message: error?.message || String(error) });
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
}
}
if (!payResult || !usedMint) {
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
log('all-mints-failed', { attemptErrors });
throw new Error(`Failed to pay invoice. ${detail || 'No mint could complete melt'}`);
}
log('paid', { usedMint, paidAmountSats, preimage: payResult?.preimage || null });
const tx = appendDirectTransaction({
type: 'pay',
amount: Number.isFinite(paidAmountSats) && paidAmountSats > 0
@@ -4403,6 +4715,7 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
}
})();
} catch (error) {
log('error', { message: error?.message || String(error), stack: error?.stack || null });
port.postMessage({ type: 'response', requestId, error: error.message });
}
}
@@ -5181,6 +5494,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 +6184,339 @@ 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.
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 allBroadcastUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
// --- Live broadcast progress streaming ------------------------------------
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per
// relay as each completes. We attach listeners BEFORE publish() and stream
// progress to all pages via broadcast() so footers can show the count.
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set(); // explicit rejections (auth-required, blocked, etc.)
const timedOutSoFar = new Set(); // timeouts
const totalTarget = isBroadcast ? allBroadcastUrls.length : 0;
// Track current batch for progress display. Updated by the batch loop.
let currentBatch = 0;
let totalBatches = 0;
if (isBroadcast && totalTarget > 0) {
console.log(`[Worker] Broadcasting to ${allBroadcastUrls.length} relays ` +
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
`${skippedRelayUrls.size} skipped)`);
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 + timedOutSoFar.size,
latestRelay: url,
batchCurrent: currentBatch,
batchTotal: totalBatches,
});
});
ndkEvent.on('relay:publish:failed', (relay, err) => {
const url = relay?.url || String(relay || '');
if (!url) return;
const errMsg = err?.message || String(err) || '';
const isTimeout = /timeout/i.test(errMsg);
if (isTimeout) {
timedOutSoFar.add(url);
} else {
failedSoFar.add(url);
}
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: url,
latestError: errMsg,
batchCurrent: currentBatch,
batchTotal: totalBatches,
});
});
}
// --- Batched publishing for broadcasts ------------------------------------
// Browsers can only handle ~50-100 simultaneous WebSocket connections.
// Publishing to 600+ relays in one shot causes most connections to queue
// and time out before getting a slot. We split the relay URLs into batches
// of 25 and publish to each batch sequentially, keeping the number of
// simultaneous connections manageable.
const BATCH_SIZE = 25;
const PER_RELAY_TIMEOUT_MS = 10000; // 10s per relay within a batch
let relaySet = null;
if (isBroadcast && totalTarget > 0 && NDKRelaySet?.fromRelayUrls) {
// --- Phase 1: Publish to the user's standard outbox relays first ---
// This ensures the post lands on the user's primary relays quickly,
// and lets us send the response back to the page so the composer
// clears immediately. The broadcast relay batches run afterward.
if (outboxWriteUrls.length > 0) {
console.log(`[Worker] Phase 1: Publishing to ${outboxWriteUrls.length} outbox relays first`);
try {
const outboxSet = NDKRelaySet.fromRelayUrls(outboxWriteUrls, ndk);
await ndkEvent.publish(outboxSet, 10000, 1);
} catch (outboxErr) {
console.warn('[Worker] Outbox publish error (expected if some relays fail):', outboxErr?.message || outboxErr);
}
console.log(`[Worker] Phase 1 complete: ${publishedSoFar.size} outbox relays succeeded`);
}
// --- Send response back to the page NOW so the composer clears ---
// The post is already on the user's outbox relays. The broadcast
// batches continue in the background.
const earlyRelayResults = {
successful: Array.from(publishedSoFar),
failed: [],
};
port.postMessage({
type: 'response',
requestId: requestId,
data: {
success: true,
relayResults: earlyRelayResults,
totalRelays: earlyRelayResults.successful.length,
}
});
console.log('[Worker] Sent early publish response — composer can clear now');
// --- Phase 2: Broadcast to remaining relays in batches (background) ---
// Only publish to relays that aren't already in the outbox set
// (those were already published in Phase 1).
const broadcastOnlyUrls = activeBroadcastUrls.filter(u => !outboxWriteUrls.includes(u));
if (broadcastOnlyUrls.length > 0) {
const batches = [];
for (let i = 0; i < broadcastOnlyUrls.length; i += BATCH_SIZE) {
batches.push(broadcastOnlyUrls.slice(i, i + BATCH_SIZE));
}
totalBatches = batches.length;
console.log(`[Worker] Phase 2: Broadcasting to ${broadcastOnlyUrls.length} relays in ${totalBatches} batches of ${BATCH_SIZE}`);
// Emit a progress event showing batch info before starting batches.
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: null,
batchCurrent: 0,
batchTotal: totalBatches,
});
for (let bi = 0; bi < batches.length; bi++) {
currentBatch = bi + 1; // update before publish so per-relay events include it
const batch = batches[bi];
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
if (batchSet?.relays) {
for (const relay of batchSet.relays) {
try {
if (relay.connectionTimeout !== undefined) {
relay.connectionTimeout = PER_RELAY_TIMEOUT_MS;
}
} catch (_) { /* relay may be read-only */ }
}
}
try {
await ndkEvent.publish(batchSet, PER_RELAY_TIMEOUT_MS, 1);
} catch (batchErr) {
// Expected — some relays in this batch may fail.
}
// Emit a progress event with batch info after each batch.
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size + timedOutSoFar.size,
latestRelay: null,
batchCurrent: bi + 1,
batchTotal: totalBatches,
});
console.log(`[Worker] Batch ${bi + 1}/${totalBatches} complete — ` +
`${publishedSoFar.size}/${totalTarget} total succeeded, ` +
`${failedSoFar.size + timedOutSoFar.size} failed/timeout so far`);
}
}
// Final broadcast progress message with complete results.
const totalFailed = failedSoFar.size + timedOutSoFar.size;
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: totalTarget,
successful: publishedSoFar.size,
failed: totalFailed,
latestRelay: null,
relayUrls: Array.from(publishedSoFar),
});
try {
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
} catch (_cleanupErr) { /* ignore */ }
// --- Failure marking (background, after all batches) ---
// Skip-mark both explicit rejections AND timeouts. A relay that
// times out is either down, too slow, or unreachable — skip it
// next time to avoid wasting time on it.
const allFailed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
const newSkips = allFailed
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
const isTimeout = timedOutSoFar.has(url);
skippedRelayUrls.set(url, {
reason: isTimeout ? 'publish-timeout' : 'publish-rejected',
timestamp: Math.floor(Date.now() / 1000),
});
}
scheduleSkipMarkSave();
const timeoutCount = newSkips.filter(u => timedOutSoFar.has(u)).length;
const rejectCount = newSkips.length - timeoutCount;
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays ` +
`(${rejectCount} rejected, ${timeoutCount} timed out)`);
}
// Return early — we already sent the response above. The rest of
// handlePublish (relayResults, kind 10002 sync, etc.) is skipped for
// broadcasts since we've handled everything here.
return;
} else {
// Normal (non-broadcast) publish — await the full result as before.
try {
relaySet = await ndkEvent.publish(null, undefined, undefined);
} catch (publishError) {
console.warn('[Worker] publish() threw:', publishError?.message || publishError);
relaySet = null;
}
}
// For non-broadcast publishes, emit a simplified broadcastProgress 'done'
// event so post cards can still show the relay count (e.g., "3r - 1h").
// We use the relaySet result to count successful relays.
if (!isBroadcast) {
const successCount = relaySet ? relaySet.size : 0;
const successUrls = relaySet
? Array.from(relaySet).map(r => r?.url || '').filter(Boolean)
: [];
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventId: ndkEvent.id || null,
eventKind: event.kind,
phase: 'done',
total: successCount,
successful: successCount,
failed: 0,
latestRelay: null,
relayUrls: successUrls,
});
}
// 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);
// For broadcast publishes, use the live-tracked sets from the
// relay:published / relay:publish:failed events as the source of truth
// (they cover temporary relays that may not be in ndk.pool). For normal
// publishes, fall back to the pool-scan logic.
if (isBroadcast) {
relayResults.successful = Array.from(publishedSoFar);
// Include both explicit rejections and timeouts in the failed list
// for display purposes, but keep them distinguishable for skip-marking.
relayResults.failed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
} else {
// 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);
console.log('[Worker] ✅ Published to relays:', relayResults.successful.length, relayResults.successful.slice(0, 10), relayResults.successful.length > 10 ? `... +${relayResults.successful.length - 10} more` : '');
console.log('[Worker] ❌ Failed relays:', relayResults.failed.length);
// --- Failure marking for broadcast relays ---------------------------------
// Skip-mark both explicit rejections AND timeouts. A relay that times
// out is either down, too slow, or unreachable — skip it next time.
if (isBroadcast) {
const allFailed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
const newSkips = allFailed
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
const isTimeout = timedOutSoFar.has(url);
skippedRelayUrls.set(url, {
reason: isTimeout ? 'publish-timeout' : 'publish-rejected',
timestamp: Math.floor(Date.now() / 1000),
});
}
scheduleSkipMarkSave();
const timeoutCount2 = newSkips.filter(u => timedOutSoFar.has(u)).length;
const rejectCount2 = newSkips.length - timeoutCount2;
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays ` +
`(${rejectCount2} rejected, ${timeoutCount2} timed out)`);
}
}
// 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 +7673,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');

View File

@@ -158,7 +158,7 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
<span id="spanBroadcastStatus"></span>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
@@ -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)
: '';
const batchInfo = (d.batchTotal && d.batchTotal > 0)
? ` · ${d.batchCurrent}/${d.batchTotal} batches`
: '';
const relayInfo = shortRelay ? ` · ${shortRelay}` : '';
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays${batchInfo}${relayInfo}`;
} else if (d.phase === 'done') {
// Keep the result displayed until the next publish overwrites it.
spanBroadcastStatus.textContent = `${d.successful}/${d.total} relays` + (d.failed > 0 ? ` (${d.failed} failed)` : '');
}
});
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);

View File

@@ -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();
<span style="flex:1;">Show connection history</span>
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
</div>
<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 id="divBroadcastStatus" style="font-size:75%;margin-top:4px;min-height:1em;"></div>
</div>
</div>
`;
// 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 = '<div style="font-size:75%;opacity:0.6;">No active broadcast relays.</div>';
} else {
listEl.innerHTML = activeUrls.map((url) => `
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
<span style="flex:1;word-break:break-all;">${escapeHtml(url)}</span>
<button data-broadcast-remove="${escapeHtml(url)}" style="font-size:75%;padding:0 4px;">×</button>
</div>
`).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 = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div><div style="font-size:75%;">None skipped.</div>';
} else {
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>' +
skipped.map((item) => {
const ts = item.timestamp
? new Date(item.timestamp * 1000).toLocaleString()
: '';
return `
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
<span style="flex:1;word-break:break-all;">
${escapeHtml(item.url)}
<div style="font-size:70%;opacity:0.8;">${escapeHtml(item.reason || 'failed')}${ts ? ' · ' + escapeHtml(ts) : ''}</div>
</span>
<button data-broadcast-unskip="${escapeHtml(item.url)}" style="font-size:75%;padding:0 4px;">Unskip</button>
</div>
`;
}).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();