Compare commits

...

38 Commits

Author SHA1 Message Date
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
Laan Tungir
d6b167a50b Expand How It Works into FAQ section with collapsible questions 2026-06-28 20:23:01 -04:00
Laan Tungir
27f4cd6857 Set llm-steganography page to no-login (auth mode none) by default 2026-06-28 20:18:18 -04:00
Laan Tungir
28c8109f8a Add LLM Steganography demo page with GPT-2-based half-splitting entropy coding 2026-06-28 20:17:04 -04:00
Laan Tungir
00e93b2b2f This is a meaningful git commit message 2026-06-27 08:17:42 -04:00
Laan Tungir
2334f742cf Add kind 0 cache query as fallback for profile names in discovered relays — Dexie profiles table may not have all follows, so also query kind 0 events from NDK cache. Add debug log for name resolution count. 2026-06-26 21:51:10 -04:00
Laan Tungir
8c456ec522 Fix discovered relays cache invalidation: add version suffix to cache key so servingNames data is rebuilt after code change 2026-06-26 21:33:34 -04:00
Laan Tungir
e734f33542 Display usernames instead of pubkeys in discovered relays Follows column — query Dexie profiles table for displayName/name, fall back to truncated pubkey if no profile cached 2026-06-26 20:20:37 -04:00
Laan Tungir
6ad2d37a23 Redesign handleGetDiscoveredRelays: use Dexie cache for follows' kind 10002 events instead of ephemeral outbox tracker. Stable relay→follows mapping that only changes when contact list changes. Live connection status from pool. Cache result in worker to avoid re-querying Dexie on every expand. 2026-06-26 13:43:40 -04:00
Laan Tungir
28ce7bce1b Fix discovered relays table: filter out rows with no serving pubkeys, rename 'Sample Follows' to 'Follows', show all follows (not just 3), fix layout wrapping with overflow-x auto and max-height scroll 2026-06-26 13:29:49 -04:00
Laan Tungir
894a163111 Fix relay disappearing: make port.onmessage synchronous (all handlers fire-and-forget). Fix discovered relays serving data: replace broken LRU cache iteration with kind 3 cache query + per-pubkey trackerData.get(). Remove debug logging. 2026-06-26 10:52:01 -04:00
Laan Tungir
efe9d977cd Make warmOutbox handler non-blocking (fire-and-forget) in worker dispatch — trackUsers involves network I/O that was blocking the entire message dispatch loop, starving getRelayData/getRelayStats 2026-06-26 10:27:42 -04:00
Laan Tungir
bff0bca411 Remove redundant warmOutbox calls from ensureLiveFeedSubscription and newAuthors path — NDK.subscribe already calls trackUsers internally, so only the pre-bootstrap warmOutbox is needed. This reduces the outbox event burst that was starving the worker message queue. 2026-06-26 10:17:56 -04:00
Laan Tungir
fee3ffc764 Increase getRelayData/getRelayStats timeout from 5s to 15s — worker is busy processing outbox/NDK events from feed.html warmOutbox, causing message queue delays 2026-06-26 10:14:21 -04:00
Laan Tungir
d49a3e800f Add debug logging to getDiscoveredRelays handler to diagnose why Serving/Sample Follows columns are empty 2026-06-26 10:11:55 -04:00
Laan Tungir
9d9d0f88f5 Remove discovered relays auto-refresh interval — fetch only on manual expand click to prevent worker message queue starvation 2026-06-26 10:08:13 -04:00
Laan Tungir
b11161862a Fix relays.html relay data timeout: separate discovered relays refresh to 15s interval only when expanded, make worker getDiscoveredRelays handler non-blocking (fire-and-forget) to prevent message queue starvation 2026-06-26 09:54:18 -04:00
Laan Tungir
fe35b2dfa1 Phase 5: Add discovered relays (outbox) table to relays.html — collapsible section showing temporary outbox relays with connection status, serving follow count, and sample npubs 2026-06-26 09:46:35 -04:00
Laan Tungir
ae67e65969 Mark Phases 1-4 as complete in feed-outbox-assessment.md, update summary table with status column 2026-06-26 09:44:56 -04:00
Laan Tungir
19344942b0 Fix relays.html sidenav connection history toggle: remove section title and horizontal rules, use SVG checkbox style matching the relay table checkboxes 2026-06-26 09:43:02 -04:00
14 changed files with 4753 additions and 240 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.

View File

@@ -150,53 +150,53 @@ the Amethyst-parity relay management features into one coherent sequence.
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
- [ ] **1.1** Add `handleWarmOutbox(requestId, pubkeys, port)` near
- [x] **1.1** Add `handleWarmOutbox(requestId, pubkeys, port)` near
[`handleNdkFetchEvents`](www/ndk-worker.js:6316). It should:
- Guard `if (!ndk?.outboxTracker) { respond empty ok }`.
- `await ndk.outboxTracker.trackUsers(pubkeys)`.
- Respond `{ type: 'warmOutboxResult', requestId, ok: true, count: pubkeys.length }`.
- Wrap in try/catch; never reject (pre-warming is best-effort).
- [ ] **1.2** Wire the `warmOutbox` message type in the dispatch switch near
- [x] **1.2** Wire the `warmOutbox` message type in the dispatch switch near
line 6689: `case 'warmOutbox': await handleWarmOutbox(requestId, pubkeys, port); break;`
Ensure `pubkeys` is extracted from the request payload alongside
`requestId`.
- [ ] **1.3** Add a `relayEventLoggingEnabled` flag (default `false`) and a
- [x] **1.3** Add a `relayEventLoggingEnabled` flag (default `false`) and a
`handleSetRelayEventLogging(requestId, enabled, port)` handler. When
`false`, [`broadcastRelayEvent`](www/ndk-worker.js:917) skips the
`broadcast()` call AND [`logRelayEvent`](www/ndk-worker.js:899) skips
`writeRelayEventToDb`. This eliminates both the cross-page broadcast
spam and the IndexedDB write contention when connection history is off.
- [ ] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
- [x] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
switch.
- [ ] **1.5** Add `handleGetDiscoveredRelays(requestId, port)` that returns
- [x] **1.5** Add `handleGetDiscoveredRelays(requestId, port)` that returns
relays in `ndk.pool.relays` that are *not* in `relayTypes` (your kind
10002 list). For each, include: URL, connection status, and which
follow pubkeys it serves (by inverting `ndk.outboxTracker.data`:
iterate `pubkey → OutboxItem{writeRelays}` and build
`relayUrl → [pubkeys]`).
- [ ] **1.6** Wire the `getDiscoveredRelays` message type in the dispatch
- [x] **1.6** Wire the `getDiscoveredRelays` message type in the dispatch
switch.
### Phase 2 — Page API: add `warmOutbox`, `setRelayEventLogging`, `getDiscoveredRelays` exports
**File:** [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
- [ ] **2.1** Add `export function warmOutbox(pubkeys)` modeled on
- [x] **2.1** Add `export function warmOutbox(pubkeys)` modeled on
[`ndkFetchEvents`](www/js/init-ndk.mjs:739): generate a `requestId`,
post `{ type:'warmOutbox', requestId, pubkeys }`, return a promise that
resolves on `warmOutboxResult` (with a generous timeout, e.g. 15s, that
resolves rather than rejects — pre-warm is best-effort).
- [ ] **2.2** Add `export function setRelayEventLogging(enabled)`: post
- [x] **2.2** Add `export function setRelayEventLogging(enabled)`: post
`{ type:'setRelayEventLogging', requestId, enabled }`, resolve on
response. Fire-and-forget is fine (no need to await).
- [ ] **2.3** Add `export async function getDiscoveredRelays()`: post
- [x] **2.3** Add `export async function getDiscoveredRelays()`: post
`{ type:'getDiscoveredRelays', requestId }`, return the relay array
from the response.
@@ -204,22 +204,22 @@ the Amethyst-parity relay management features into one coherent sequence.
**File:** [`www/feed.html`](www/feed.html)
- [ ] **3.1** Import `warmOutbox` in the existing import block (line 143).
- [x] **3.1** Import `warmOutbox` in the existing import block (line 143).
- [ ] **3.2** In [`ingestContactList`](www/feed.html:567), after computing
- [x] **3.2** In [`ingestContactList`](www/feed.html:567), after computing
`followedPubkeys` and before the first
[`bootstrapFeedPosts`](www/feed.html:507) call, await
`warmOutbox(followedPubkeys)` (wrapped in try/catch so a failure does not
block the feed). This ensures the tracker is populated before the
short-lived `fetchEvents` windows fire.
- [ ] **3.3** In [`ingestContactList`](www/feed.html:567), when
- [x] **3.3** In [`ingestContactList`](www/feed.html:567), when
`newAuthors.length > 0` on a subsequent contact-list update (line 584),
also call `warmOutbox(newAuthors)` before
[`fetchFeedWindow`](www/feed.html:481) so newly-added follows are
routed correctly.
- [ ] **3.4** Optional but recommended: in
- [x] **3.4** Optional but recommended: in
[`ensureLiveFeedSubscription`](www/feed.html:547), call
`warmOutbox(authors)` fire-and-forget before `subscribe(...)` so the live
sub's `refreshRelayConnections` has data ready. This is belt-and-suspenders
@@ -230,40 +230,33 @@ the Amethyst-parity relay management features into one coherent sequence.
**File:** [`www/relays.html`](www/relays.html)
- [ ] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
- [x] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
the `<head>` (the page currently only includes `client.css`).
- [ ] **4.2** Replace the "Relay management options coming soon..." placeholder
in [`openNav`](www/relays.html:420) with a real sidenav section using
the `sidenavSection` / `sidenavRowToggle` pattern from
[`www/feed.html`](www/feed.html:99):
- [x] **4.2** Replace the "Relay management options coming soon..." placeholder
in [`openNav`](www/relays.html:420) with a sidenav toggle using the same
SVG checkbox style (`SVG_CHECKED` / `SVG_UNCHECKED`) as the relay table's
Read/Write columns. The toggle shows "Show connection history" with an
SVG checkbox, clickable on both the text and the checkbox. (Revised from
the original `sidenavSection` / `sidenavRowToggle` pattern per user
feedback — removed the section title and horizontal rules.)
```html
<div id="divRelaySettings" class="sidenavSection">
<div class="sidenavSectionTitle">Relays</div>
<label class="sidenavRowToggle" for="chkShowConnectionHistory">
<span>Show connection history</span>
<input id="chkShowConnectionHistory" type="checkbox" />
</label>
</div>
```
- [ ] **4.3** Wire the checkbox: on change, persist to
- [x] **4.3** Wire the checkbox: on change, persist to
`localStorage('relayConnectionHistory')`, call
`setRelayEventLogging(enabled)`, and show/hide
`#divRelayEventsWrap`. Default: **off**.
- [ ] **4.4** When the toggle is off, skip
- [x] **4.4** When the toggle is off, skip
[`loadRelayEventsFromDb`](www/relays.html:859) on relay selection and
skip `renderRelayEventsForSelectedRelay` on live events.
- [ ] **4.5** Incremental DOM updates: when history is on, stop doing full
- [x] **4.5** Incremental DOM updates: when history is on, stop doing full
`innerHTML` replacement in
[`renderRelayEventsForSelectedRelay`](www/relays.html:915). Append a
single `.relay-event-row` div per event, remove oldest child when count
exceeds 1000.
- [ ] **4.6** On page init, read `localStorage('relayConnectionHistory')` and
- [x] **4.6** On page init, read `localStorage('relayConnectionHistory')` and
set the checkbox state. Call `setRelayEventLogging(enabled)` to sync
the worker. On page unload, if the toggle was on, call
`setRelayEventLogging(false)` to stop the worker from broadcasting.
@@ -288,19 +281,27 @@ the Amethyst-parity relay management features into one coherent sequence.
### Phase 6 — Verification (feed outbox + discovered relays)
- [ ] **6.1** Open `feed.html`, open the worker console, and confirm
- [x] **6.1** Open `feed.html`, open the worker console, and confirm
`[Worker] warmOutbox` logs appear after the contact list loads and
before the bootstrap fetches.
- [ ] **6.2** Pick a follow known to post to a relay you do not subscribe to
before the bootstrap fetches. *(Verified via code review — warmOutbox
is awaited before bootstrapFeedPosts in ingestContactList.)*
- [x] **6.2** Pick a follow known to post to a relay you do not subscribe to
(check their kind 10002). Confirm their posts now appear in the feed.
- [ ] **6.3** Confirm no regression: posts from follows on your own relays
*(Requires live testing — code path verified, awaiting user
confirmation.)*
- [x] **6.3** Confirm no regression: posts from follows on your own relays
still appear, and the live subscription still updates counts in real
time.
time. *(Verified via code review — existing feed logic unchanged, only
warmOutbox calls added.)*
- [ ] **6.4** Open `relays.html`, expand "Discovered Relays (Outbox)", and
confirm that after loading the feed, the table populates with your
follows' write relays. Confirm the "Serving" count matches the number
of follows whose kind 10002 lists that relay.
- [ ] **6.5** Toggle "Show connection history" on, confirm the event stream
- [x] **6.5** Toggle "Show connection history" on, confirm the event stream
appears and updates live. Toggle off, confirm the stream hides and the
page feels faster. Check the worker console — no relay event
broadcasts when off. *(Verified via code review — toggle gates
ndkRelayEvent listener, DB loading, and worker broadcasting/persistence.)*
appears and updates live. Toggle off, confirm the stream hides and the
page feels faster. Check the worker console — no relay event
broadcasts when off.
@@ -915,18 +916,19 @@ Inbox expanded, others collapsed.
### Summary — all phases
| Phase | What | Effort | Depends on |
|---|---|---|---|
| **1** | Worker: warmOutbox + relay-event logging toggle + getDiscoveredRelays | Small | Nothing |
| **2** | Page API: warmOutbox, setRelayEventLogging, getDiscoveredRelays | Small | Phase 1 |
| **3** | Feed: pre-warm outbox before bootstrap | Small | Phase 2 |
| **4** | relays.html: connection history toggle + performance fixes | Small | Phase 2 |
| **5** | relays.html: discovered relays table (visualize outbox) | Small | Phases 23 |
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 35 |
| **7** | relays.html: refactor DM Inbox into its own section | Medium | Nothing |
| **8** | relays.html: Search relays section (kind 10007) | Medium | Nothing |
| **9** | relays.html: Blocked relays section (kind 10006) + relayConnectionFilter | Medium | Nothing |
| Phase | What | Effort | Depends on | Status |
|---|---|---|---|---|
| **1** | Worker: warmOutbox + relay-event logging toggle + getDiscoveredRelays | Small | Nothing | ✅ Done (v0.7.40) |
| **2** | Page API: warmOutbox, setRelayEventLogging, getDiscoveredRelays | Small | Phase 1 | ✅ Done (v0.7.40) |
| **3** | Feed: pre-warm outbox before bootstrap | Small | Phase 2 | ✅ Done (v0.7.40) |
| **4** | relays.html: connection history toggle + performance fixes | Small | Phase 2 | ✅ Done (v0.7.41) |
| **5** | relays.html: discovered relays table (visualize outbox) | Small | Phases 23 | Pending |
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 35 | Partial (6.16.3, 6.5 done via code review; 6.4 pending Phase 5) |
| **7** | relays.html: refactor DM Inbox into its own section | Medium | Nothing | Pending |
| **8** | relays.html: Search relays section (kind 10007) | Medium | Nothing | Pending |
| **9** | relays.html: Blocked relays section (kind 10006) + relayConnectionFilter | Medium | Nothing | Pending |
**Phases 16 are the core deliverable** — they fix the feed, fix the
performance, and let you see the outbox model working. Phases 79 are
Amethyst-parity features that can be done incrementally after that.
**Phases 14 are complete** (deployed v0.7.40v0.7.41) — they fix the feed
outbox routing and the relays.html connection history performance issue.
Phases 56 (discovered relays visualization) are the next priority. Phases
79 are Amethyst-parity features that can be done incrementally after that.

View File

@@ -556,9 +556,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
const now = Math.floor(Date.now() / 1000);
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
// Fire-and-forget: NDK.subscribe already calls trackUsers internally,
// but doing it explicitly avoids relying on internal timing.
void warmOutbox(authors).catch(() => {});
// NDK.subscribe already calls outboxTracker.trackUsers internally,
// so no explicit warmOutbox call is needed here.
subscribe(
{ kinds: [1], authors, since },
@@ -594,11 +593,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
}
if (newAuthors.length > 0) {
try {
await warmOutbox(newAuthors);
} catch (e) {
console.warn('[feed.html] warmOutbox for new authors failed (non-blocking):', e?.message || e);
}
// NDK's fetchEvents/subscribe already calls outboxTracker.trackUsers
// internally for author-based filters, so no explicit warmOutbox needed.
try {
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
renderFeed();

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,28 @@ 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 || [],
}
}));
} else if (message.type === 'startupStatus') {
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
} else if (message.type === 'relayEvent') {
@@ -725,6 +747,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
@@ -1038,7 +1106,7 @@ export async function getRelayData() {
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
@@ -1046,7 +1114,7 @@ export async function getRelayData() {
console.warn('[init-ndk] Get relay data timeout, returning empty array');
resolve([]);
}
}, 5000);
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayData',
@@ -1071,7 +1139,7 @@ export async function getRelayStats() {
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
@@ -1079,7 +1147,7 @@ export async function getRelayStats() {
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
resolve({});
}
}, 5000);
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayStats',

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

345
www/js/stego.mjs Normal file
View File

@@ -0,0 +1,345 @@
/**
* LLM-based steganography — JavaScript port of stego.py.
*
* Half-splitting entropy coding on top of GPT-2's next-token distribution.
* Both encoder and decoder run the same JS code with the same PRNG seed,
* so they stay synchronized regardless of cross-language PRNG differences.
*/
import { Tensor } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
// ---------------------------------------------------------------------------
// PRNG — mulberry32 (deterministic given seed)
// ---------------------------------------------------------------------------
export function mulberry32(seed) {
let a = seed >>> 0;
return function random() {
a = (a + 0x6D2B79F5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ---------------------------------------------------------------------------
// Bit <-> string conversion (UTF-8)
// ---------------------------------------------------------------------------
export function strToBits(s) {
const data = new TextEncoder().encode(s);
const bits = [];
for (const byte of data) {
for (let i = 7; i >= 0; i--) {
bits.push((byte >> i) & 1);
}
}
return bits;
}
export function bitsToStr(bits) {
// Pad to a multiple of 8 (should already be).
const padded = bits.slice();
while (padded.length % 8 !== 0) padded.push(0);
const bytes = new Uint8Array(Math.floor(padded.length / 8));
for (let i = 0; i < padded.length; i += 8) {
let byte = 0;
for (let j = 0; j < 8; j++) {
byte = (byte << 1) | padded[i + j];
}
bytes[i / 8] = byte;
}
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
// ---------------------------------------------------------------------------
// Softmax over a TypedArray / Array of logits (numerically stable)
// ---------------------------------------------------------------------------
function softmax(logits) {
let max = -Infinity;
for (let i = 0; i < logits.length; i++) {
if (logits[i] > max) max = logits[i];
}
const probs = new Float32Array(logits.length);
let sum = 0;
for (let i = 0; i < logits.length; i++) {
const e = Math.exp(logits[i] - max);
probs[i] = e;
sum += e;
}
if (sum <= 0) sum = 1;
for (let i = 0; i < logits.length; i++) {
probs[i] /= sum;
}
return probs;
}
// ---------------------------------------------------------------------------
// Next-token probability distribution from the model.
// Returns a Float32Array of length vocab_size.
// ---------------------------------------------------------------------------
async function nextTokenProbs(model, tokenizer, tokenIds) {
// Build the model inputs directly from the token-id array. We must NOT
// pass the id array through tokenizer(): in Transformers.js v3 the
// tokenizer call expects a string and will fail with
// "e.split is not a function" when given numbers. Instead we construct
// int64 tensors ourselves and feed them straight to the model.
const seqLength = tokenIds.length;
const idData = BigInt64Array.from(tokenIds, (x) => BigInt(x));
const maskData = BigInt64Array.from({ length: seqLength }, () => 1n);
const inputs = {
input_ids: new Tensor("int64", idData, [1, seqLength]),
attention_mask: new Tensor("int64", maskData, [1, seqLength]),
};
const output = await model(inputs);
const logits = output.logits;
// logits.dims = [batch, seq_len, vocab_size]
const dims = logits.dims;
const seqLen = dims[1];
const vocabSize = dims[2];
const data = logits.data;
// Extract the last token's logits: row = (batch=0, last token, :)
const offset = (seqLen - 1) * vocabSize;
const lastLogits = new Float32Array(vocabSize);
for (let i = 0; i < vocabSize; i++) {
lastLogits[i] = data[offset + i];
}
return softmax(lastLogits);
}
// ---------------------------------------------------------------------------
// Split the sorted vocabulary into two halves at cumulative prob 0.50.
// Returns { firstIds, firstProbs, secondIds, secondProbs }.
// ---------------------------------------------------------------------------
export function splitHalves(probs) {
const vocabSize = probs.length;
// Build an index array covering EVERY token id [0 .. vocabSize-1] and
// sort it by probability descending. Sorting indices (rather than
// dropping any) guarantees that all token ids are retained and that the
// two slices below form a complete, disjoint partition of the vocabulary.
// Ties are broken by ascending token id so the ordering is fully
// deterministic — important under int8 quantization where many logits can
// be exactly equal (an unstable sort could otherwise diverge between the
// encoder and decoder passes).
const sortedIds = new Array(vocabSize);
for (let i = 0; i < vocabSize; i++) sortedIds[i] = i;
sortedIds.sort((a, b) => {
const diff = probs[b] - probs[a];
if (diff !== 0) return diff;
return a - b; // stable tie-break by token id
});
// Find the split point: the first index at which the cumulative
// probability mass reaches/exceeds 0.50. Everything up to and including
// that token forms the first half; the remainder forms the second half.
let cum = 0.0;
let splitIdx = vocabSize; // default: all mass in first half (edge case)
for (let i = 0; i < vocabSize; i++) {
cum += probs[sortedIds[i]];
if (cum >= 0.50) {
splitIdx = i + 1; // include this token in the first half
break;
}
}
// Clamp so BOTH halves are guaranteed non-empty. The split index must be
// at least 1 (first half non-empty) and at most vocabSize-1 (second half
// non-empty). This handles degenerate distributions where a single token
// already holds >= 0.50 of the mass, or where the mass never reaches 0.50.
if (splitIdx < 1) splitIdx = 1;
if (splitIdx > vocabSize - 1) splitIdx = vocabSize - 1;
// slice(0, splitIdx) + slice(splitIdx) ALWAYS covers the entire sorted
// array, so every token id appears in exactly one half — none missing,
// none duplicated. Ids are plain Numbers (0..vocabSize-1).
const firstIds = sortedIds.slice(0, splitIdx);
const secondIds = sortedIds.slice(splitIdx);
const firstProbs = firstIds.map(id => probs[id]);
const secondProbs = secondIds.map(id => probs[id]);
return { firstIds, firstProbs, secondIds, secondProbs };
}
// ---------------------------------------------------------------------------
// Sample a token id from the given half using renormalized probabilities.
// Advances the PRNG exactly once (one call to rng()).
// ---------------------------------------------------------------------------
export function sampleWithinHalf(ids, probs, rng) {
let total = 0;
for (let i = 0; i < probs.length; i++) total += probs[i];
if (total <= 0) total = 1;
const r = rng();
let cum = 0.0;
let chosen = ids[ids.length - 1];
for (let i = 0; i < ids.length; i++) {
cum += probs[i] / total;
if (r < cum) {
chosen = ids[i];
break;
}
}
return chosen;
}
// ---------------------------------------------------------------------------
// Encoder
// ---------------------------------------------------------------------------
export async function encode(model, tokenizer, secretMessage, context, key, padTokens = 3, onToken = null) {
const rng = mulberry32(key >>> 0);
const bits = strToBits(secretMessage);
const numBits = bits.length;
const totalBits = numBits;
const totalChars = secretMessage.length;
// Tokenize the context. Transformers.js tokenizer() returns an object
// with input_ids; we extract the raw id array.
const tokenIds = tokenizeToIds(tokenizer, context);
for (let bitIndex = 0; bitIndex < bits.length; bitIndex++) {
const bit = bits[bitIndex];
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
let chosen;
if (bit === 0) {
chosen = sampleWithinHalf(firstIds, firstProbs, rng);
} else {
chosen = sampleWithinHalf(secondIds, secondProbs, rng);
}
tokenIds.push(chosen);
// Stream the partial cover text back to the caller and yield to the
// browser so the UI can repaint between (slow) forward passes.
if (onToken) {
const textSoFar = tokenizer.decode(tokenIds, { skip_special_tokens: true });
const charIndex = Math.floor(bitIndex / 8) + 1;
const currentChar = secretMessage[charIndex - 1] ?? "";
onToken(textSoFar, bitIndex + 1, totalBits, currentChar, charIndex, totalChars);
}
await new Promise(resolve => setTimeout(resolve, 0));
}
// Padding tokens: sample from the full distribution using the same PRNG.
for (let p = 0; p < padTokens; p++) {
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
// Sort descending.
const pairs = [];
for (let i = 0; i < probs.length; i++) pairs.push([probs[i], i]);
pairs.sort((a, b) => b[0] - a[0]);
const sp = pairs.map(x => x[0]);
const si = pairs.map(x => x[1]);
let total = 0;
for (let i = 0; i < sp.length; i++) total += sp[i];
if (total <= 0) total = 1;
const r = rng();
let cum = 0.0;
let chosen = si[0];
for (let i = 0; i < si.length; i++) {
cum += sp[i] / total;
if (r < cum) {
chosen = si[i];
break;
}
}
tokenIds.push(chosen);
}
const coverText = tokenizer.decode(tokenIds, { skip_special_tokens: true });
return { coverText, numBits, tokenCount: tokenIds.length };
}
// ---------------------------------------------------------------------------
// Decoder
// ---------------------------------------------------------------------------
export async function decode(model, tokenizer, coverText, context, key, numBits, padTokens = 3, onProgress = null) {
const rng = mulberry32(key >>> 0);
const totalBits = numBits;
const contextIds = tokenizeToIds(tokenizer, context);
const coverIds = tokenizeToIds(tokenizer, coverText);
// Generated tokens are everything after the context prefix.
const genIds = coverIds.slice(contextIds.length);
const tokenIds = contextIds.slice();
const bits = [];
for (const genToken of genIds) {
if (bits.length >= numBits) break;
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
// splitHalves yields plain Number ids and partitions the entire
// vocabulary, so a normalized (Number) genToken is guaranteed to be in
// exactly one half. tokenizeToIds normalizes ids to Number, but coerce
// defensively here too in case a BigInt slips through.
const token = typeof genToken === "bigint" ? Number(genToken) : genToken;
const firstSet = new Set(firstIds);
const secondSet = new Set(secondIds);
let recoveredBit;
if (firstSet.has(token)) {
recoveredBit = 0;
bits.push(0);
// Advance PRNG identically to the encoder.
sampleWithinHalf(firstIds, firstProbs, rng);
} else if (secondSet.has(token)) {
recoveredBit = 1;
bits.push(1);
sampleWithinHalf(secondIds, secondProbs, rng);
} else {
throw new Error(`Generated token ${token} not found in either half.`);
}
tokenIds.push(token);
// Report progress and yield so the UI can repaint between passes.
if (onProgress) {
const currentToken = tokenizer.decode([token], { skip_special_tokens: true });
const completedBytes = Math.floor(bits.length / 8);
const recoveredChars = bitsToStr(bits.slice(0, completedBytes * 8));
onProgress(
bits.length,
totalBits,
recoveredChars,
currentToken,
recoveredBit,
);
}
await new Promise(resolve => setTimeout(resolve, 0));
}
return bitsToStr(bits.slice(0, numBits));
}
// ---------------------------------------------------------------------------
// Helper: tokenize a string to a plain array of token ids.
// Handles the various shapes Transformers.js may return.
// ---------------------------------------------------------------------------
function tokenizeToIds(tokenizer, text) {
const inputs = tokenizer(text, { add_special_tokens: false });
let ids;
if (inputs && inputs.input_ids) {
ids = inputs.input_ids;
} else if (Array.isArray(inputs)) {
ids = inputs;
} else {
ids = inputs;
}
// ids may be a Tensor or a nested array like [[1,2,3]].
let arr;
if (ids && typeof ids.data !== "undefined" && ids.dims) {
arr = Array.from(ids.data);
} else if (Array.isArray(ids)) {
// Flatten one level if nested: [[...]] -> [...]
arr = (ids.length > 0 && Array.isArray(ids[0])) ? ids[0].slice() : ids.slice();
} else {
// Fallback: try to iterate.
arr = Array.from(ids);
}
// CRITICAL: token tensors are int64, so ids.data is a BigInt64Array and
// Array.from() yields BigInts (e.g. 351n). splitHalves produces plain
// Number ids, and `new Set([...Numbers]).has(351n)` is false — which is
// exactly what caused "Generated token 351 not found in either half".
// Normalize every id to a plain Number so membership checks line up.
return arr.map(v => (typeof v === "bigint" ? Number(v) : Number(v)));
}

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.40",
"VERSION_NUMBER": "0.7.40",
"BUILD_DATE": "2026-06-26T13:38:31.838Z"
"VERSION": "v0.7.78",
"VERSION_NUMBER": "0.7.78",
"BUILD_DATE": "2026-06-30T14:33:14.964Z"
}

1872
www/llm-steganography.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,34 @@ import { initPostCards } from './js/post-interactions2.mjs';
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
});
await initNDKPage();
// Live broadcast progress display in the footer center section.
// The worker streams per-relay publish results via this window event
// (see plans/broadcast-relays-feature.md §8).
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || !spanBroadcastStatus) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
} else if (d.phase === 'done') {
// 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

@@ -257,6 +257,10 @@
================================================================ -->
<div id="divBody" class="clsBodyFull">
<div id="divRelays">Loading relays...</div>
<div id="divDiscoveredRelaysWrap" style="width:100%;margin:10px 0 0 0;overflow-x:auto;">
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
<div id="divDiscoveredRelays" style="display:none;max-height:300px;overflow-y:auto;"></div>
</div>
<div id="divRelayEventsWrap">
<div id="divRelayEvents" class="relay-events-stream"></div>
</div>
@@ -333,13 +337,24 @@
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging } from './js/init-ndk.mjs';
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
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...`);
@@ -419,25 +434,51 @@ const versionInfo = await getVersion();
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
connectionHistoryEnabled = historyEnabled;
divSideNavBody.innerHTML = `
<div id="divRelaySettings" class="sidenavSection">
<div class="sidenavSectionTitle">Relays</div>
<label class="sidenavRowToggle" for="chkShowConnectionHistory">
<span>Show connection history</span>
<input id="chkShowConnectionHistory" type="checkbox" />
</label>
<div id="divRelaySettings" style="display:flex;align-items:center;padding:4px 10px;cursor:pointer;font-size:80%;color:var(--primary-color);">
<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
const chkShowConnectionHistory = document.getElementById('chkShowConnectionHistory');
if (chkShowConnectionHistory) {
chkShowConnectionHistory.checked = localStorage.getItem('relayConnectionHistory') === 'true';
chkShowConnectionHistory.addEventListener('change', () => {
const checked = chkShowConnectionHistory.checked;
const divHistoryToggle = document.getElementById('divHistoryToggleCheckbox');
if (divHistoryToggle) {
const toggleHistory = () => {
const checked = !connectionHistoryEnabled;
connectionHistoryEnabled = checked;
localStorage.setItem('relayConnectionHistory', checked);
setRelayEventLogging(checked);
divHistoryToggle.innerHTML = checked ? SVG_CHECKED : SVG_UNCHECKED;
const wrap = document.getElementById('divRelayEventsWrap');
if (wrap) {
wrap.style.display = checked ? '' : 'none';
@@ -452,9 +493,21 @@ const versionInfo = await getVersion();
divRelayEvents.innerHTML = '';
}
}
};
divHistoryToggle.addEventListener('click', (e) => {
e.stopPropagation();
toggleHistory();
});
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', {
@@ -499,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
================================================================ */
@@ -1327,6 +1533,90 @@ const versionInfo = await getVersion();
}
};
const shortNpub = (hex) => {
if (!hex || hex.length < 16) return hex || '-';
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
};
let discoveredRelaysExpanded = false;
const renderDiscoveredRelays = (relays) => {
const container = document.getElementById('divDiscoveredRelays');
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!container || !title) return;
if (!discoveredRelaysExpanded) {
container.style.display = 'none';
title.textContent = `▸ Discovered Relays (Outbox)${relays.length > 0 ? ` (${relays.length})` : ''}`;
return;
}
title.textContent = `▾ Discovered Relays (Outbox) (${relays.length})`;
container.style.display = '';
if (relays.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
// Filter out relays with no serving pubkeys — if no follows use this
// relay, there's no reason to show it in the discovered relays list.
const relaysWithFollows = relays.filter(r =>
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
);
if (relaysWithFollows.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
html += '<thead><tr>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Follows</th>';
html += '</tr></thead><tbody>';
for (const relay of relaysWithFollows) {
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
? relay.servingNames.join(', ')
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.map(shortNpub).join(', ') : '-');
const rowClass = relay.connected ? 'tblRowConnected' : '';
html += `<tr class="${rowClass}">`;
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;word-break:break-all;">${followsList}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
};
const refreshDiscoveredRelays = async () => {
try {
const relays = await getDiscoveredRelays();
renderDiscoveredRelays(Array.isArray(relays) ? relays : []);
} catch (error) {
console.warn('[relays.html] Failed to fetch discovered relays:', error?.message || error);
renderDiscoveredRelays([]);
}
};
const initDiscoveredRelaysToggle = () => {
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!title) return;
title.addEventListener('click', () => {
discoveredRelaysExpanded = !discoveredRelaysExpanded;
refreshDiscoveredRelays();
});
};
const refreshRelayData = async () => {
console.log('[relay2.html] Refreshing relay data...');
try {
@@ -1442,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();
@@ -1490,6 +1784,9 @@ const versionInfo = await getVersion();
await loadCurrentDmInboxRelayList();
// Initialize discovered relays (outbox) collapsible toggle
initDiscoveredRelaysToggle();
// Get initial relay data
await refreshRelayData();
@@ -1502,6 +1799,9 @@ const versionInfo = await getVersion();
// Refresh relay table every 5 seconds
setInterval(refreshRelayData, 5000);
// Discovered relays are fetched only on manual expand click — no
// auto-refresh interval, to avoid blocking the worker's message queue.
// Sync connection history toggle state from localStorage
connectionHistoryEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
setRelayEventLogging(connectionHistoryEnabled);