Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f31e1c301 | ||
|
|
c9a20e63b5 | ||
|
|
79e38b8f79 | ||
|
|
7cfd43b91b | ||
|
|
777ab312a5 | ||
|
|
2763dd9d6a | ||
|
|
a20b28cd21 | ||
|
|
456f0e1f2e | ||
|
|
4abb4bf33c | ||
|
|
7dcfc7c3e3 | ||
|
|
07f5f39f35 | ||
|
|
10ddda633c | ||
|
|
c55a5ebfd8 | ||
|
|
b6e945f8de | ||
|
|
3ec585273f | ||
|
|
0e8229e13e | ||
|
|
230bd70e07 | ||
|
|
c36cb4a0fe | ||
|
|
d6b167a50b | ||
|
|
27f4cd6857 | ||
|
|
28c8109f8a | ||
|
|
00e93b2b2f | ||
|
|
2334f742cf | ||
|
|
8c456ec522 | ||
|
|
e734f33542 | ||
|
|
6ad2d37a23 | ||
|
|
28ce7bce1b | ||
|
|
894a163111 | ||
|
|
efe9d977cd | ||
|
|
bff0bca411 | ||
|
|
fee3ffc764 | ||
|
|
d49a3e800f | ||
|
|
9d9d0f88f5 | ||
|
|
b11161862a | ||
|
|
fe35b2dfa1 | ||
|
|
ae67e65969 | ||
|
|
19344942b0 | ||
|
|
c758333269 | ||
|
|
3ed0c35611 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# GPT-2 model files (271MB, too large for git — uploaded directly to server)
|
||||
www/models/
|
||||
|
||||
hamburger_morphing/
|
||||
client/
|
||||
ndk/
|
||||
|
||||
122
plans/broadcast-batching-fix.md
Normal file
122
plans/broadcast-batching-fix.md
Normal 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
|
||||
713
plans/broadcast-relays-feature.md
Normal file
713
plans/broadcast-relays-feature.md
Normal 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.
|
||||
934
plans/feed-outbox-assessment.md
Normal file
934
plans/feed-outbox-assessment.md
Normal file
@@ -0,0 +1,934 @@
|
||||
# Feed Outbox Model — Assessment & Plan
|
||||
|
||||
## Your suspicion is correct, and NDK already has the machinery built in.
|
||||
|
||||
You are missing posts from some follows because the feed only queries the
|
||||
**relays you subscribe to** (your read/both relays from your kind 10002 list).
|
||||
If a follow posts exclusively to relays you are *not* connected to, those posts
|
||||
never arrive. This is exactly the problem the Nostr **outbox model** (NIP-65 +
|
||||
NIP-17 relay lists) solves: instead of only listening on your relays, you look
|
||||
up each follow's kind 10002 relay list and fetch their posts from *their* write
|
||||
relays.
|
||||
|
||||
The good news: **NDK's outbox model is already enabled** in your bundle and is
|
||||
already wired into the subscription path. The bad news: it is being defeated by
|
||||
a race condition in how [`www/feed.html`](www/feed.html) bootstraps the feed.
|
||||
|
||||
---
|
||||
|
||||
## How NDK's outbox model works (already in your bundle)
|
||||
|
||||
Relevant code in [`www/ndk-core.bundle.js`](www/ndk-core.bundle.js):
|
||||
|
||||
1. **Outbox is on by default** — [`NDK`](www/ndk-core.bundle.js:43195) constructor
|
||||
at line 43295: `if (!(opts.enableOutboxModel === false))` creates an
|
||||
`outboxPool` (default relays `wss://purplepag.es/`, `wss://nos.lol/`) and an
|
||||
`outboxTracker`. Your worker never sets `enableOutboxModel: false`, so it is
|
||||
active. Confirmed: [`www/ndk-worker.js`](www/ndk-worker.js:1506) `ndkOptions`
|
||||
does not disable it.
|
||||
|
||||
2. **Author-based subscriptions trigger outbox tracking** —
|
||||
[`NDK.subscribe`](www/ndk-core.bundle.js:43665): when a filter has `authors`,
|
||||
it calls `this.outboxTracker?.trackUsers(authors)`. This fetches each
|
||||
author's kind 10002 (and kind 3 fallback) relay list from the outbox pool.
|
||||
|
||||
3. **Relay sets are computed per-author** —
|
||||
[`calculateRelaySetsFromFilter`](www/ndk-core.bundle.js:33864) calls
|
||||
[`getRelaysForFilterWithAuthors`](www/ndk-core.bundle.js:31650) →
|
||||
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31594) which
|
||||
picks ~2 write relays per author and splits the `authors` array across the
|
||||
relays each author actually writes to.
|
||||
|
||||
4. **Late outbox data refreshes subscriptions** — when
|
||||
[`OutboxTracker`](www/ndk-core.bundle.js:42790) resolves a user's relay list,
|
||||
it emits `user:relay-list-updated`, and the NDK constructor (line 43301)
|
||||
calls `subscription.refreshRelayConnections()` to add newly-discovered
|
||||
relays to the live subscription.
|
||||
|
||||
5. **Fallback for unknown authors** —
|
||||
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31639): authors
|
||||
with no known relay list are sent to `pool.permanentAndConnectedRelays()`
|
||||
(your read relays). So outbox never *loses* events vs. the old behavior; it
|
||||
only *adds* coverage.
|
||||
|
||||
So in principle, a plain `ndk.subscribe({ kinds:[1], authors:[...] })` already
|
||||
does outbox routing. The feature you want exists.
|
||||
|
||||
---
|
||||
|
||||
## Why your feed still misses people — the root cause
|
||||
|
||||
The problem is a **race / staleness condition** in
|
||||
[`www/feed.html`](www/feed.html), not a missing NDK feature.
|
||||
|
||||
### The bootstrap path (lines 481–545)
|
||||
|
||||
[`fetchFeedWindow`](www/feed.html:481) does this for each time window:
|
||||
|
||||
1. `queryCache(filters)` — reads the Dexie cache synchronously.
|
||||
2. `ndkFetchEvents(filters)` — fires a relay subscription with
|
||||
`closeOnEose: true` (via [`handleNdkFetchEvents`](www/ndk-worker.js:6316)).
|
||||
|
||||
The filters are `{ kinds:[1], authors, since, limit }`. This *does* go through
|
||||
NDK's outbox routing. But there are two issues:
|
||||
|
||||
#### Issue A — Outbox data is not pre-warmed
|
||||
|
||||
`outboxTracker.trackUsers` is called inside `NDK.subscribe`, but it is
|
||||
**fire-and-forget** relative to `EOSE`. The subscription starts immediately
|
||||
against whatever relays are currently known (initially: your read relays only,
|
||||
since the tracker is empty). When the outbox data arrives moments later,
|
||||
`refreshRelayConnections` adds the new relays — but only for **long-lived**
|
||||
subscriptions. For a `closeOnEose: true` `fetchEvents` call, the subscription
|
||||
may `EOSE` and close on your read relays *before* the outbox tracker finishes
|
||||
resolving the follow's kind 10002 lists. Result: you get posts only from
|
||||
follows who happen to post to your read relays.
|
||||
|
||||
This is the core bug. The bootstrap windows in
|
||||
[`FEED_BOOTSTRAP_WINDOWS_SECONDS`](www/feed.html:179) fire a burst of
|
||||
short-lived `fetchEvents` calls that race the outbox tracker.
|
||||
|
||||
#### Issue B — The live subscription is fine, but late
|
||||
|
||||
[`ensureLiveFeedSubscription`](www/feed.html:547) creates a long-lived
|
||||
`subscribe({ kinds:[1], authors, since })` with `closeOnEose:false`. This one
|
||||
*does* benefit from `refreshRelayConnections` — once the outbox tracker
|
||||
resolves, new relays get added and late posts stream in. But:
|
||||
|
||||
- It only covers events **after** `since` (newest known − 30s, or now − 1h).
|
||||
Historical posts from follows on foreign relays that arrived *before* the
|
||||
outbox data resolved are never backfilled.
|
||||
- The bootstrap is what fills the initial feed view, and that's where Issue A
|
||||
bites.
|
||||
|
||||
#### Issue C — Outbox tracker entries expire in 2 minutes
|
||||
|
||||
[`OutboxTracker`](www/ndk-core.bundle.js:42800): `entryExpirationTimeInMS: 2 * 60 * 1000`.
|
||||
The tracker is an LRU with a 2-minute TTL. If the live subscription was created
|
||||
and the tracker entries expired, a later `refreshRelayConnections` won't fire
|
||||
because there's no `user:relay-list-updated` event to re-trigger it. The
|
||||
subscription keeps running on whatever relays it had. This is mostly fine for
|
||||
the live sub, but means any *new* `fetchEvents` call (e.g. "See More" or a new
|
||||
follow added via [`ingestContactList`](www/feed.html:567)) starts cold again.
|
||||
|
||||
---
|
||||
|
||||
## What does NOT need to change
|
||||
|
||||
- **Do not** disable the outbox model or build a custom relay-fetching layer.
|
||||
NDK already does this; you'd be reimplementing
|
||||
[`getRelayListForUsers`](www/ndk-core.bundle.js:42662) and
|
||||
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31594).
|
||||
- **Do not** manually connect to every follow's write relays in the main pool.
|
||||
NDK's `pool.getRelay(url, true, true)` (temporary relay) already handles
|
||||
per-subscription temporary connections via
|
||||
[`calculateRelaySetsFromFilter`](www/ndk-core.bundle.js:33864). Adding them
|
||||
permanently would bloat the main pool.
|
||||
|
||||
## What DOES need to change
|
||||
|
||||
The fix is to **pre-warm the outbox tracker before issuing the short-lived
|
||||
bootstrap fetches**, so that by the time `fetchEvents` runs, NDK already knows
|
||||
each follow's write relays and routes the subscription correctly.
|
||||
|
||||
NDK exposes this via `ndk.outboxTracker.trackUsers(pubkeys)`. Your worker does
|
||||
not currently call it directly. The cleanest fix is a new worker RPC,
|
||||
`warmOutbox(pubkeys)`, that calls `ndk.outboxTracker.trackUsers(pubkeys)` and
|
||||
awaits it, plus a feed.html change to call it before
|
||||
[`bootstrapFeedPosts`](www/feed.html:507).
|
||||
|
||||
---
|
||||
|
||||
## Unified Implementation Plan
|
||||
|
||||
The work is organized into phases that build on each other. Each phase is
|
||||
self-contained and deliverable independently. The phases merge the feed
|
||||
outbox fix, the relays.html performance fix, the outbox visualization, and
|
||||
the Amethyst-parity relay management features into one coherent sequence.
|
||||
|
||||
### Phase 1 — Worker: expose outbox pre-warming + relay-event logging toggle
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
- [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).
|
||||
|
||||
- [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`.
|
||||
|
||||
- [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.
|
||||
|
||||
- [x] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
|
||||
switch.
|
||||
|
||||
- [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]`).
|
||||
|
||||
- [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)
|
||||
|
||||
- [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).
|
||||
|
||||
- [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).
|
||||
|
||||
- [x] **2.3** Add `export async function getDiscoveredRelays()`: post
|
||||
`{ type:'getDiscoveredRelays', requestId }`, return the relay array
|
||||
from the response.
|
||||
|
||||
### Phase 3 — Feed: pre-warm outbox before bootstrap
|
||||
|
||||
**File:** [`www/feed.html`](www/feed.html)
|
||||
|
||||
- [x] **3.1** Import `warmOutbox` in the existing import block (line 143).
|
||||
|
||||
- [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.
|
||||
|
||||
- [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.
|
||||
|
||||
- [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
|
||||
since `NDK.subscribe` already calls `trackUsers`, but doing it explicitly
|
||||
avoids relying on the internal call's timing.
|
||||
|
||||
### Phase 4 — relays.html: connection history toggle + performance fixes
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [x] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
|
||||
the `<head>` (the page currently only includes `client.css`).
|
||||
|
||||
- [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.)
|
||||
|
||||
- [x] **4.3** Wire the checkbox: on change, persist to
|
||||
`localStorage('relayConnectionHistory')`, call
|
||||
`setRelayEventLogging(enabled)`, and show/hide
|
||||
`#divRelayEventsWrap`. Default: **off**.
|
||||
|
||||
- [x] **4.4** When the toggle is off, skip
|
||||
[`loadRelayEventsFromDb`](www/relays.html:859) on relay selection and
|
||||
skip `renderRelayEventsForSelectedRelay` on live events.
|
||||
|
||||
- [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.
|
||||
|
||||
- [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.
|
||||
|
||||
### Phase 5 — relays.html: discovered relays table (visualize outbox)
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **5.1** Add a collapsible "Discovered Relays (Outbox)" section below the
|
||||
main relay table. Columns: Relay URL | Connected | Serving (count of
|
||||
follows) | Sample Follows (first 3 npubs). Read-only — no toggles, no
|
||||
remove.
|
||||
|
||||
- [ ] **5.2** Call `getDiscoveredRelays()` on page init and on each
|
||||
`refreshRelayData` cycle (every 5s). Render the results in the new
|
||||
section.
|
||||
|
||||
- [ ] **5.3** Add a small "Outbox discovery" status line showing the outbox
|
||||
pool relays (`purplepag.es`, `nos.lol`) and their connection state.
|
||||
This is informational — "Outbox discovery: connected to purplepag.es,
|
||||
nos.lol".
|
||||
|
||||
### Phase 6 — Verification (feed outbox + discovered relays)
|
||||
|
||||
- [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. *(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.
|
||||
*(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. *(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.
|
||||
- [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.
|
||||
|
||||
---
|
||||
|
||||
## Why this is the minimal, correct fix
|
||||
|
||||
- It uses NDK's existing outbox infrastructure — no custom relay logic.
|
||||
- It only changes the *timing* of when the tracker is populated, not the
|
||||
routing algorithm.
|
||||
- It is best-effort and non-blocking: if pre-warming fails or times out, the
|
||||
feed falls back to the current behavior (your read relays only), which is
|
||||
strictly a subset of what outbox routing would return.
|
||||
- It directly addresses the race between short-lived `fetchEvents` EOSE and
|
||||
the async `trackUsers` resolution.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **Outbox pool relays:** NDK's default outbox pool is `purplepag.es` and
|
||||
`nos.lol`. These are the relays it queries for kind 10002 lists. If they are
|
||||
slow or rate-limiting, `trackUsers` can take up to its 1s timeout per batch
|
||||
(see [`getRelayListForUsers`](www/ndk-core.bundle.js:42662) `timeout = 1e3`).
|
||||
The 15s page-side timeout in Phase 2.1 accommodates this for ~400-pubkey
|
||||
batches.
|
||||
- **Large follow lists:** `trackUsers` batches in slices of 400
|
||||
([`OutboxTracker.trackUsers`](www/ndk-core.bundle.js:42810)). A user with
|
||||
1000+ follows will take a few batches. This is fine; the live sub still
|
||||
works during pre-warm.
|
||||
- **2-minute TTL:** If a user leaves the feed tab open for a long time, the
|
||||
tracker entries expire. The live subscription keeps its already-added relays,
|
||||
so this is not a problem for ongoing streaming. It only matters for *new*
|
||||
`fetchEvents` calls, which is why Phase 3.3 re-warms for new follows.
|
||||
|
||||
---
|
||||
|
||||
## Footer & sidenav relay visualization — keeping it unchanged
|
||||
|
||||
You want the footer and sidenav relay indicators (managed by
|
||||
[`www/js/relay-ui.mjs`](www/js/relay-ui.mjs)) to continue showing only your
|
||||
subscribed relays, unchanged. I traced the data flow to confirm our changes
|
||||
won't affect them, and identified one minor edge case to guard against.
|
||||
|
||||
### How the footer/sidenav gets relay data today
|
||||
|
||||
1. [`prepareRelayVisualizationData`](www/js/relay-ui.mjs:235) calls
|
||||
`getRelayData()` → worker's
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6401).
|
||||
2. `handleGetRelayData` builds the relay list from `relayTypes` (your kind
|
||||
10002 map) — **not** from `ndk.pool.relays`. This is the key line:
|
||||
[`relayTypes.size > 0 ? Array.from(relayTypes.entries()) : ...`](www/ndk-worker.js:6415).
|
||||
So once your kind 10002 is loaded, the footer only ever shows your chosen
|
||||
relays, regardless of what temporary relays are in the pool.
|
||||
3. Activity carrots (read/write animations) are driven by
|
||||
`ndkRelayActivity` window events, which come from
|
||||
[`trackRelayRead`](www/ndk-worker.js:812) /
|
||||
[`trackRelayWrite`](www/ndk-worker.js:834). These broadcast
|
||||
`{ type: 'relayActivity', ... }` — a **separate** broadcast from
|
||||
`broadcastRelayEvent` (the debug event stream).
|
||||
|
||||
### Impact of our changes on the footer/sidenav
|
||||
|
||||
| Change | Affects footer/sidenav? | Why |
|
||||
|---|---|---|
|
||||
| Phase 1: `warmOutbox` | **No** | Only calls `ndk.outboxTracker.trackUsers`. Does not touch `relayTypes` or `handleGetRelayData`. |
|
||||
| Phase 1: `setRelayEventLogging` | **No** | Only gates `broadcastRelayEvent` / `logRelayEvent`. Does NOT gate `trackRelayRead` / `trackRelayWrite` (the activity broadcasts). Activity carrots continue working. |
|
||||
| Phase 1: `getDiscoveredRelays` | **No** | New RPC, only called by relays.html. Does not touch `handleGetRelayData`. |
|
||||
| Phase 3: feed pre-warm | **No** | Adds temporary relays to `ndk.pool`, but `handleGetRelayData` uses `relayTypes`, not the pool. |
|
||||
| Phase 9: `relayConnectionFilter` | **No** | Filters which relays NDK connects to, but does not change `relayTypes` or `handleGetRelayData`. |
|
||||
|
||||
### The one edge case to guard against
|
||||
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6415) has a fallback: when
|
||||
`relayTypes.size === 0` (before your kind 10002 is fetched), it uses
|
||||
`ndk.pool.relays.keys()`. After our outbox fix, temporary outbox relays will
|
||||
be in `ndk.pool.relays`, so during this brief startup window the footer could
|
||||
show discovered relays that aren't yours.
|
||||
|
||||
This is **transient** — `relayTypes` populates within seconds of login when
|
||||
the worker fetches your kind 10002. But to be safe, we should guard the
|
||||
fallback:
|
||||
|
||||
- [ ] **Guard 1 — Filter the fallback (optional).** In
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6417), when using the
|
||||
`ndk.pool.relays.keys()` fallback, filter out temporary relays. NDK
|
||||
relays added via `useTemporaryRelay` may have a way to detect their
|
||||
temporary status. If not practical to detect, leave as-is — the
|
||||
fallback is only used for a few seconds at startup and the visual
|
||||
impact is minimal (a few extra icons that disappear once `relayTypes`
|
||||
loads).
|
||||
|
||||
This guard is **optional** — the fallback is already transient and
|
||||
self-correcting. It's listed here for completeness, not as a required step.
|
||||
|
||||
### What we will NOT change
|
||||
|
||||
- [`www/js/relay-ui.mjs`](www/js/relay-ui.mjs) — no changes. The footer and
|
||||
sidenav rendering logic stays exactly as-is.
|
||||
- [`handleGetRelayData`](www/ndk-worker.js:6401) — no changes to the primary
|
||||
path (the `relayTypes` branch). It will continue to show only your kind
|
||||
10002 relays.
|
||||
- `trackRelayRead` / `trackRelayWrite` — no changes. Activity carrots
|
||||
continue to fire on your subscribed relays.
|
||||
|
||||
---
|
||||
|
||||
## Relay UI implications — how outbox relays should appear
|
||||
|
||||
This is an important design question. Once outbox routing is active, NDK will
|
||||
be connecting to **three categories** of relays, but your current UI only
|
||||
surfaces one of them. Here is how they map to NDK's internal pools and what
|
||||
your UI should do about each.
|
||||
|
||||
### The three categories of relays NDK uses
|
||||
|
||||
1. **Main pool — your read/both relays** (from your kind 10002). These are the
|
||||
relays you chose in `relay.html`. Managed by
|
||||
[`updateRelays`](www/ndk-worker.js:2088), which only adds read/both relays
|
||||
to `ndk.pool` (write-only relays are deliberately excluded — line 2131).
|
||||
These are what [`handleGetRelayData`](www/ndk-worker.js:6401) reports today.
|
||||
|
||||
2. **Outbox pool — relay-list discovery relays** (`purplepag.es`, `nos.lol`).
|
||||
This is `ndk.outboxPool`, a separate `NDKPool` instance created in the NDK
|
||||
constructor ([`www/ndk-core.bundle.js:43296`](www/ndk-core.bundle.js:43296)).
|
||||
It is used *only* to fetch kind 10002 / kind 3 relay lists for authors. It
|
||||
does **not** carry your feed posts. Your worker never touches it directly.
|
||||
|
||||
3. **Temporary relays — your follows' write relays.** When outbox routing
|
||||
resolves a follow's kind 10002, NDK calls
|
||||
[`pool.useTemporaryRelay`](www/ndk-core.bundle.js:35529) to connect to that
|
||||
follow's write relays on demand, scoped to the subscription that needs them.
|
||||
These relays are added to `ndk.pool.relays` but marked temporary — they are
|
||||
removed after 30s of inactivity (`removeIfUnusedAfter = 3e4`). **These are
|
||||
the relays that actually carry the missing posts.**
|
||||
|
||||
### What your UI currently shows
|
||||
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6401) builds the relay list from
|
||||
`relayTypes` (your kind 10002 map) or, as a fallback, `ndk.pool.relays.keys()`.
|
||||
This means:
|
||||
|
||||
- **Category 1** is always shown. ✅
|
||||
- **Category 2** (outbox pool) is never shown — it's a separate pool object
|
||||
the worker doesn't read. This is arguably correct: these relays don't carry
|
||||
your content, they're just a directory service. Showing them would clutter
|
||||
the UI with relays the user didn't pick and can't edit.
|
||||
- **Category 3** (temporary relays) is *partially* visible: they exist in
|
||||
`ndk.pool.relays`, so the fallback path at line 6417 would include them.
|
||||
But once `relayTypes` is populated (after the first kind 10002 fetch), the
|
||||
fallback is not used and temporary relays are **hidden**. They also come and
|
||||
go every 30s, which would make the footer flicker if shown.
|
||||
|
||||
### Recommended UI treatment
|
||||
|
||||
The cleanest mental model for users is: **"My relays" vs. "Discovered relays."**
|
||||
|
||||
- **Footer + sidenav (relay-ui.mjs):** Keep showing only **Category 1** — the
|
||||
user's own read/both relays. This is what the user controls and what
|
||||
[`relay.html`](www/relay.html) edits. Showing temporary outbox relays here
|
||||
would be noisy (they appear/disappear every 30s) and misleading (the user
|
||||
can't disconnect or edit them). The footer should answer "are *my* relays
|
||||
healthy?", not "what is NDK connected to right now?"
|
||||
|
||||
→ **No change required to [`www/js/relay-ui.mjs`](www/js/relay-ui.mjs).**
|
||||
The current `handleGetRelayData` already filters to `relayTypes`, which is
|
||||
exactly right.
|
||||
|
||||
- **relay.html (the relay management page):** Consider adding a **read-only
|
||||
"Discovered relays" section** below the editable relay table. This would
|
||||
surface Category 3 (temporary relays currently in the pool) so a curious
|
||||
user can see which of their follows' relays NDK is pulling from. This is
|
||||
informational only — no add/remove/edit. Implementation would be a new
|
||||
worker RPC `getDiscoveredRelays` that returns
|
||||
`Array.from(ndk.pool.relays.values()).filter(r => r is temporary && not in
|
||||
relayTypes)` with their status and which follow(s) they serve.
|
||||
|
||||
This is **optional for the initial outbox fix** and can be deferred. The
|
||||
core feed fix (Phases 1–3) does not depend on it.
|
||||
|
||||
- **Outbox pool (Category 2):** Do not surface in the UI. These are
|
||||
infrastructure relays (a directory service), not content relays. If a user
|
||||
wants to know why relay-list discovery is slow, that's a debug/diagnostic
|
||||
concern, not a relay-management concern.
|
||||
|
||||
### Why not show temporary relays in the footer
|
||||
|
||||
Concrete reasons, in case this comes up in review:
|
||||
|
||||
1. **Flicker.** Temporary relays are removed after 30s of inactivity
|
||||
([`useTemporaryRelay`](www/ndk-core.bundle.js:35529)
|
||||
`removeIfUnusedAfter = 3e4`). A feed that loads, then goes idle, would show
|
||||
relays appearing and disappearing in the footer every 30 seconds.
|
||||
2. **No user control.** The footer relays are clickable and the sidenav relays
|
||||
are editable. Temporary relays can't be edited — they're driven by follows'
|
||||
kind 10002 lists. Showing them as if they were user relays violates the
|
||||
edit affordance.
|
||||
3. **Scale.** A user following 500 people across 200 distinct relays would
|
||||
see a footer full of icons they didn't choose. The footer is a health
|
||||
indicator for *your* setup, not a live connection map.
|
||||
4. **Redundancy.** [`setRelayActivityState`](www/js/relay-ui.mjs:145) already
|
||||
shows read/write activity carrots on *your* relays when they carry traffic.
|
||||
If a follow's post comes through a temporary relay and then gets re-fetched
|
||||
on your relay (or vice versa), the activity indicator on your relay still
|
||||
fires. The user sees activity; they don't need to see the temporary relay
|
||||
itself.
|
||||
|
||||
### Summary
|
||||
|
||||
| Relay category | Pool | Carries feed posts? | Show in footer/sidenav? | Show in relay.html? |
|
||||
|---|---|---|---|---|
|
||||
| 1. Your read/both relays | `ndk.pool` (permanent) | Yes | Yes (current behavior) | Yes, editable (current) |
|
||||
| 2. Outbox discovery relays | `ndk.outboxPool` | No (only kind 10002/3) | No | No |
|
||||
| 3. Follows' write relays | `ndk.pool` (temporary, 30s TTL) | Yes | No | Optional: read-only section |
|
||||
|
||||
The feed outbox fix (Phases 1–3) requires **no changes** to the relay UI.
|
||||
The optional "Discovered relays" view in relay.html can be a follow-up task.
|
||||
|
||||
---
|
||||
|
||||
## Amethyst comparison — what the most comprehensive Nostr client does
|
||||
|
||||
I examined [`~/lt/amethyst`](../amethyst) (both the Android `amethyst/` module and
|
||||
the `desktopApp/` module, plus the shared `commons/` and `quartz/` libraries).
|
||||
Amethyst is indeed the most thorough relay implementation in the Nostr
|
||||
ecosystem. Here is what it does that your client does not, and what it would
|
||||
take to bring your client to parity.
|
||||
|
||||
### Amethyst's relay set architecture
|
||||
|
||||
Amethyst does not have one relay list. It has **eleven distinct relay lists**,
|
||||
each backed by a different Nostr event kind, each serving a different purpose.
|
||||
From [`Account.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:330)
|
||||
and the quartz event definitions:
|
||||
|
||||
| Relay list | NIP / Kind | Purpose | Your client has it? |
|
||||
|---|---|---|---|
|
||||
| NIP-65 Outbox/Inbox | 10002 | Where you publish / where others find you | ✅ Yes ([`relay.html`](www/relay.html)) |
|
||||
| DM Inbox | 10050 (NIP-17) | Where others send you encrypted DMs | ❌ No |
|
||||
| Search | 10007 (NIP-50) | Relays for full-text search queries | ❌ No |
|
||||
| Blocked | 10006 (NIP-51) | Relays you refuse to connect to | ❌ No |
|
||||
| Trusted | NIP-51 private | Relays you trust for Tor routing | ❌ No |
|
||||
| Proxy | NIP-51 private | Relays to use as proxies | ❌ No |
|
||||
| Broadcast | NIP-51 private | Extra relays to broadcast every event to | ❌ No |
|
||||
| Indexer | NIP-51 private | Relays for kind/author indexing lookups | ❌ No |
|
||||
| Relay Feeds | NIP-51 private | Relays that serve as feed sources | ❌ No |
|
||||
| Private Storage | 10037 (NIP-37) | Private outbox relays for drafts | ❌ No |
|
||||
| KeyPackage | 10051 (MIP-00) | MLS key package discovery relays | ❌ No |
|
||||
|
||||
On top of these **eleven published lists**, Amethyst computes **five derived
|
||||
relay sets** by merging sources ([`Account.kt:415-462`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:415)):
|
||||
|
||||
| Derived set | Sources | Used for |
|
||||
|---|---|---|
|
||||
| `homeRelays` | NIP-65 + private + local | Home feed subscriptions |
|
||||
| `outboxRelays` | NIP-65 + private + local + broadcast | Where you publish your events |
|
||||
| `dmRelays` | DM list + NIP-65 + private + local | DM send/receive |
|
||||
| `notificationRelays` | NIP-65 read + local | Notifications/zap receipts |
|
||||
| `followPlusAllMineWithIndex` | Follows' outboxes + your NIP-65 + indexer | Global feed with indexing |
|
||||
| `followPlusAllMineWithSearch` | Follows' outboxes + your NIP-65 + search | Global feed with search |
|
||||
| `defaultGlobalRelays` | Follows' outboxes + your NIP-65 | Global feed fallback |
|
||||
|
||||
The key insight: **Amethyst's "outbox model" is not just NDK's
|
||||
`outboxTracker`**. It is a multi-layer system where:
|
||||
|
||||
1. **Your own relay lists** (11 kinds) define where *you* publish and what
|
||||
*you* refuse.
|
||||
2. **Derived sets** merge your lists with your follows' NIP-65 lists to
|
||||
compute the actual relay set for each subscription type.
|
||||
3. **Per-event routing** ([`Account.kt:1130-1290`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1130))
|
||||
chooses relays dynamically: reactions go to the original author's inbox
|
||||
relays + your outbox; replies go to the parent author's inbox + tagged
|
||||
users' inboxes + your outbox; deletions go to your outbox + the original
|
||||
event's relays.
|
||||
|
||||
### What Amethyst's UI shows
|
||||
|
||||
The desktop relay settings screen
|
||||
([`RelayConfigTab.kt`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt:56))
|
||||
has **five collapsible sections**, each independently editable:
|
||||
|
||||
1. **Connected Relays** — the live pool, with add/remove. Collapsed by default.
|
||||
2. **NIP-65 Inbox/Outbox** (kind 10002) — editable, publishes a kind 10002.
|
||||
3. **DM Relays** (kind 10050) — editable, publishes a kind 10050.
|
||||
4. **Search Relays** (kind 10007) — editable, publishes a kind 10007.
|
||||
5. **Blocked Relays** (kind 10006) — editable, publishes a kind 10006.
|
||||
|
||||
Each section has its own editor component
|
||||
([`Nip65RelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt:70),
|
||||
[`DmRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt:59),
|
||||
[`SearchRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt:58),
|
||||
[`BlockedRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt:57))
|
||||
that signs and publishes the appropriate event kind.
|
||||
|
||||
There is also a **relay health system**
|
||||
([`RelayHealthStore`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStore.kt:102),
|
||||
[`ClassifyRelayHealth`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealth.kt:53))
|
||||
that monitors which relays in your lists are actually responding, classifies
|
||||
them as healthy/dead/snoozed, and shows an "Unhealthy Relays" popup with a
|
||||
one-click "remove from all lists" action via
|
||||
[`RelayListMutator.removeFromAllUserLists`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListMutator.kt:50).
|
||||
|
||||
### What it would take to add all of this to your client
|
||||
|
||||
This is a large undertaking. Here is a phased breakdown, ordered by value.
|
||||
|
||||
#### Tier 1 — High value, moderate effort (recommended next)
|
||||
|
||||
These directly improve the feed and core UX:
|
||||
|
||||
- [ ] **DM Relay List (kind 10050)** — Your client already does NIP-17 DMs
|
||||
([`www/msg.html`](www/msg.html)). Without a kind 10050 list, other
|
||||
clients don't know where to send you DMs. Add: worker fetch of kind
|
||||
10050, a `dmRelayList` state, an editor section in `relay.html`, and
|
||||
use it in the DM publish path. NDK does not manage this for you — it's
|
||||
application-level.
|
||||
|
||||
- [ ] **Search Relay List (kind 10007)** — If you have or want NIP-50 search,
|
||||
this lets the user configure which search relays to query. Add: worker
|
||||
fetch, state, editor, and use in search queries.
|
||||
|
||||
- [ ] **Blocked Relay List (kind 10006)** — A relay-level blocklist (distinct
|
||||
from your NIP-51 mute list which is pubkey-based). Add: worker fetch,
|
||||
state, editor, and a `relayConnectionFilter` on the NDK instance so
|
||||
NDK refuses to connect to blocked relays. This is the one that
|
||||
integrates with NDK: set `ndk.relayConnectionFilter = (url) =>
|
||||
!blockedRelays.has(url)` and the outbox tracker will skip blocked
|
||||
relays ([`www/ndk-core.bundle.js:42835`](www/ndk-core.bundle.js:42835)).
|
||||
|
||||
#### Tier 2 — Medium value, moderate effort
|
||||
|
||||
- [ ] **Relay health monitoring** — Periodic ping/connection checks on your
|
||||
configured relays, with a UI indicator for dead relays and a "remove"
|
||||
action. Your worker already has
|
||||
[`startRelayHealthCheck`](www/ndk-worker.js:1595) but it only
|
||||
reconnects; it doesn't classify or surface health to the UI.
|
||||
|
||||
- [ ] **Per-event publish routing** — Instead of publishing to all your
|
||||
relays, route events to the relevant relays: reactions to the original
|
||||
author's inbox relays, replies to parent author + tagged users, etc.
|
||||
This requires fetching the recipient's NIP-65 list (NDK's
|
||||
`getWriteRelaysFor` can help) and is what makes Amethyst's events
|
||||
reliably reach the right people.
|
||||
|
||||
- [ ] **Broadcast Relay List (NIP-51 private)** — Extra relays to always
|
||||
include when publishing. Some users want their events on more relays
|
||||
than their NIP-65 list. Add: NIP-44-encrypted kind 10051 list, state,
|
||||
editor, merge into `outboxRelays` at publish time.
|
||||
|
||||
#### Tier 3 — Lower value, high effort (only if needed)
|
||||
|
||||
- [ ] **Trusted / Proxy Relay Lists (NIP-51 private)** — Only relevant if you
|
||||
add Tor support. Amethyst uses these to decide which relays route
|
||||
through Tor ([`TorRelayEvaluation`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt:27)).
|
||||
Without Tor, these are unnecessary.
|
||||
|
||||
- [ ] **Indexer / Relay Feeds Lists (NIP-51 private)** — Specialized relay
|
||||
lists for kind-discovery and feed-source relays. Only useful if you
|
||||
build features that need them (e.g., "find all kind 30023 authors").
|
||||
|
||||
- [ ] **Private Storage Relay List (kind 10037)** — For NIP-37 drafts. Only
|
||||
relevant if you implement draft events.
|
||||
|
||||
- [ ] **KeyPackage Relay List (kind 10051)** — For MLS group messaging
|
||||
(MIP-00). Only relevant if you implement the marmot/MLS protocol.
|
||||
|
||||
- [ ] **Custom Relay Sets (kind 30002)** — Amethyst's quartz library defines
|
||||
[`RelaySetEvent`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt:44)
|
||||
(kind 30002, NIP-51 parameterized) for user-defined named relay sets.
|
||||
These are NIP-44-encrypted private lists with a title/description. This
|
||||
is the "many different relay sets" feature you mentioned — users can
|
||||
create arbitrary named sets (e.g., "My backup relays", "High-latency
|
||||
relays") and reference them. This is a power-user feature; it requires
|
||||
a full CRUD UI, NIP-44 encryption, and application-level logic to
|
||||
consume the sets.
|
||||
|
||||
### Architectural recommendation
|
||||
|
||||
**Do not try to replicate Amethyst's architecture wholesale.** Amethyst is a
|
||||
Kotlin/Compose app with a reactive state-flow system; your client is a
|
||||
web-worker architecture built on NDK. The key differences:
|
||||
|
||||
1. **NDK already handles outbox routing** (the `outboxTracker` + temporary
|
||||
relays). Amethyst reimplements this in
|
||||
[`FollowListOutboxOrProxyRelays`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:455)
|
||||
because its quartz relay pool does not have NDK's automatic outbox model.
|
||||
You do **not** need to reimplement follow-outbox computation — you need to
|
||||
pre-warm it (Phases 1–3 above).
|
||||
|
||||
2. **NDK does not manage application-level relay lists** (DM, search, blocked,
|
||||
etc.). These are your responsibility. Amethyst has 11 state classes for
|
||||
these; you would need equivalent worker-side state and relay.html editor
|
||||
sections.
|
||||
|
||||
3. **The highest-ROI additions** are the Blocked list (because it integrates
|
||||
with NDK's `relayConnectionFilter` and improves outbox routing safety) and
|
||||
the DM list (because you already do NIP-17 DMs but don't advertise where
|
||||
to send them). These two alone would bring your client to functional
|
||||
parity with Amethyst for most users.
|
||||
|
||||
4. **The "many relay sets" feature** (kind 30002) is the most complex and
|
||||
lowest-ROI. Defer it unless you have a specific use case. Amethyst itself
|
||||
does not heavily surface custom relay sets in its main UI — they exist in
|
||||
the quartz library but the desktop `RelayConfigTab` only shows the 5
|
||||
fixed sections.
|
||||
|
||||
### Summary: what to do now vs. later
|
||||
|
||||
| Priority | Task | Effort | Depends on feed fix? |
|
||||
|---|---|---|---|
|
||||
| **Now** | Feed outbox pre-warm (Phases 1–3) | Small | — |
|
||||
| **Now** | Discovered relays read-only view in relay.html | Small | No |
|
||||
| **Next** | Blocked relay list (kind 10006) + `relayConnectionFilter` | Medium | No |
|
||||
| **Next** | DM relay list (kind 10050) | Medium | No |
|
||||
| **Later** | Search relay list (kind 10007) | Medium | No |
|
||||
| **Later** | Relay health UI | Medium | No |
|
||||
| **Later** | Per-event publish routing | Large | No |
|
||||
| **Later** | Broadcast relay list (NIP-51) | Medium | No |
|
||||
| **Maybe** | Trusted/Proxy/Indexer/RelayFeeds/PrivateStorage/KeyPackage lists | Large | No |
|
||||
| **Maybe** | Custom relay sets (kind 30002) | Large | No |
|
||||
|
||||
---
|
||||
|
||||
## relays.html page — current state and redesign plan
|
||||
|
||||
I examined [`www/relays.html`](www/relays.html) (1464 lines). Here is what it
|
||||
does today, the problems you identified, and a concrete plan for the page.
|
||||
|
||||
### Current structure
|
||||
|
||||
The page has two main areas:
|
||||
|
||||
1. **Relay table** ([`createRelayTable`](www/relays.html:553), lines 553–748) —
|
||||
A table with columns: Remove | Relay URL | Connected | Read | Write |
|
||||
DM Inbox | Reads | Writes | Connection Time. Each row is a relay from your
|
||||
kind 10002 list. The Read/Write checkboxes toggle the NIP-65 marker and
|
||||
republish kind 10002. The DM Inbox checkbox toggles membership in your
|
||||
kind 10050 list ([`publishDmInboxRelayList`](www/relays.html:1010)). There
|
||||
is an add-relay row at the bottom with the same toggles. The table
|
||||
refreshes every 5 seconds
|
||||
([`setInterval(refreshRelayData, 5000)`](www/relays.html:1446)).
|
||||
|
||||
2. **Connection history stream** ([`divRelayEvents`](www/relays.html:260),
|
||||
lines 859–939, 1200–1272) — A chat-like log of every relay frame
|
||||
(REQ/EVENT/OK/EOSE/NOTICE/AUTH/etc.) for the selected relay. Events arrive
|
||||
via `ndkRelayEvent` window events
|
||||
([line 1405](www/relays.html:1405)), which the worker broadcasts on every
|
||||
relay message via [`broadcastRelayEvent`](www/ndk-worker.js:917). The
|
||||
worker persists each event to IndexedDB
|
||||
([`writeRelayEventToDb`](www/ndk-worker.js:709)) and prunes to 200 per
|
||||
relay every 60s ([`RELAY_EVENT_LOG_LIMIT = 200`](www/ndk-worker.js:307),
|
||||
[`RELAY_EVENTS_PRUNE_INTERVAL_MS`](www/ndk-worker.js:311)). The page loads
|
||||
history from IndexedDB on relay selection
|
||||
([`loadRelayEventsFromDb`](www/relays.html:859)) and appends live events.
|
||||
|
||||
### Problem 1 — Connection history is heavy and slows the app
|
||||
|
||||
The performance issue has three layers:
|
||||
|
||||
1. **Worker-side: every relay frame is persisted to IndexedDB.**
|
||||
[`logRelayEvent`](www/ndk-worker.js:899) calls
|
||||
`void writeRelayEventToDb(entry)` on every single relay message. On an
|
||||
active feed with 5+ relays, this is dozens of IndexedDB writes per second.
|
||||
Each write opens a transaction, adds a row, and closes. This is fire-and-
|
||||
forget but the I/O contention on the `relay-events` IndexedDB database
|
||||
competes with the NDK cache adapter's own IndexedDB traffic.
|
||||
|
||||
2. **Worker-side: every relay frame is broadcast to all pages.**
|
||||
[`broadcastRelayEvent`](www/ndk-worker.js:917) sends every event to every
|
||||
connected port. Pages that don't care about relay events (feed.html,
|
||||
msg.html, etc.) still receive and dispatch them. On `relays.html` itself,
|
||||
the `ndkRelayEvent` listener
|
||||
([line 1405](www/relays.html:1405)) calls `logRelayEvent` which appends to
|
||||
the in-memory array and re-renders the entire event stream DOM
|
||||
([`renderRelayEventsForSelectedRelay`](www/relays.html:915)) on every
|
||||
frame.
|
||||
|
||||
3. **Page-side: full DOM re-render on every event.**
|
||||
`renderRelayEventsForSelectedRelay` does `divRelayEvents.innerHTML = ...`
|
||||
with up to 1000 entries ([line 1261](www/relays.html:1261) caps at 1000).
|
||||
On a busy relay, this is a full innerHTML replacement every few
|
||||
milliseconds. This is the direct cause of the slowdown you see.
|
||||
|
||||
### Solution: a toggle for connection history
|
||||
|
||||
The fix is to make the connection history opt-in, both on the page and in the
|
||||
worker. The toggle goes in the relays.html sidenav, replacing the placeholder
|
||||
text "Relay management options coming soon..."
|
||||
([`openNav`](www/relays.html:420) sets `divSideNavBody.innerHTML` to that
|
||||
string). It uses the same `sidenavSection` / `sidenavRowToggle` pattern as
|
||||
[`www/feed.html`](www/feed.html:99) uses for "Autoplay videos":
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
This requires adding the
|
||||
[`sidenav-sections.css`](www/css/sidenav-sections.css) stylesheet to
|
||||
`relays.html` (it is not currently included — the page uses only
|
||||
`client.css`).
|
||||
|
||||
**Implementation:** This is covered by **Phase 1** (worker:
|
||||
`setRelayEventLogging` RPC + flag that stops both broadcasting and IndexedDB
|
||||
writes) and **Phase 4** (relays.html: sidenav toggle, show/hide the event
|
||||
stream, skip DB loads, incremental DOM updates) in the unified plan above.
|
||||
Default: **off** — the history is a debug tool.
|
||||
|
||||
### Problem 2 — Where to show outbox / discovered relays
|
||||
|
||||
You want to visualize the outbox model working. Today the table only shows
|
||||
your kind 10002 relays. After the feed outbox fix (Phases 1–3), NDK will be
|
||||
connecting to your follows' write relays as temporary relays — but they are
|
||||
invisible on this page.
|
||||
|
||||
The right design is a **second table below the main one**, clearly labeled as
|
||||
discovered/outbox relays, read-only.
|
||||
|
||||
These are implemented in **Phase 1** (worker RPC), **Phase 2** (page API),
|
||||
and **Phase 5** (relays.html table) of the unified plan above. The worker
|
||||
exposes `getDiscoveredRelays` (Phase 1.5–1.6), the page API wraps it (Phase
|
||||
2.3), and relays.html renders it in a collapsible "Discovered Relays
|
||||
(Outbox)" section with columns: Relay URL | Connected | Serving (count of
|
||||
follows) | Sample Follows (first 3 npubs). Read-only — no toggles, no
|
||||
remove. This is where you see the outbox model working: when you load the
|
||||
feed, this table populates with your follows' write relays. An optional
|
||||
"Outbox discovery" status line shows the outbox pool relays
|
||||
(`purplepag.es`, `nos.lol`) and their connection state.
|
||||
|
||||
### Problem 3 — Setting different relay types (Amethyst-style sections)
|
||||
|
||||
Today the table has Read / Write / DM Inbox columns. To reach Amethyst-level
|
||||
relay management, the page should grow **collapsible sections** for each
|
||||
relay list kind, rather than cramming everything into one table's columns.
|
||||
|
||||
Recommended layout (top to bottom):
|
||||
|
||||
1. **Your Relays (NIP-65, kind 10002)** — the current table, with Read/Write
|
||||
toggles. This is what you have now. Keep as-is.
|
||||
|
||||
2. **DM Inbox Relays (kind 10050)** — Currently a column in the main table.
|
||||
Move to its own section so it's not conflated with NIP-65 read/write.
|
||||
Each relay has a remove button. Add-relay input at the bottom. This is a
|
||||
refactor of the existing DM Inbox column into a dedicated section.
|
||||
|
||||
3. **Search Relays (kind 10007)** — New section. Editable list. Publishes
|
||||
kind 10007. Requires worker support to fetch/publish kind 10007 (new RPC).
|
||||
|
||||
4. **Blocked Relays (kind 10006)** — New section. Editable list. Publishes
|
||||
kind 10006. Requires worker support, and setting
|
||||
`ndk.relayConnectionFilter` so NDK refuses to connect to these relays.
|
||||
|
||||
5. **Discovered Relays (Outbox)** — Read-only, from Phase 5 above.
|
||||
|
||||
6. **Connection History** — The debug stream, behind the toggle from Phase 4.
|
||||
|
||||
Each section is collapsible (like Amethyst's
|
||||
[`CollapsibleSection`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt:168))
|
||||
so the page doesn't become overwhelming. Default state: NIP-65 expanded, DM
|
||||
Inbox expanded, others collapsed.
|
||||
|
||||
### Phase 7 — relays.html: refactor DM Inbox into its own section
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **7.1** Remove the "DM Inbox" column from the main relay table
|
||||
([`createRelayTable`](www/relays.html:553), line 591 header and line 642
|
||||
body cell).
|
||||
- [ ] **7.2** Add a new collapsible "DM Inbox Relays (kind 10050)" section
|
||||
below the main table. Each relay in `dmInboxRelays` gets a row with a
|
||||
remove button. Add-relay input at the bottom. Reuses the existing
|
||||
[`publishDmInboxRelayList`](www/relays.html:1010) logic.
|
||||
- [ ] **7.3** The add-relay row in the main table loses its DM Inbox toggle
|
||||
(line 658). DM inbox relays are added from the DM Inbox section instead.
|
||||
|
||||
### Phase 8 — relays.html: Search relays section (kind 10007)
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs), [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **8.1** Worker: add fetch/publish for kind 10007. Add a
|
||||
`handleGetSearchRelayList` handler that fetches kind 10007 for the
|
||||
current pubkey, and a `handlePublishSearchRelayList` handler that
|
||||
publishes a kind 10007 event with the relay list.
|
||||
- [ ] **8.2** Page API: add `getSearchRelayList()` and
|
||||
`publishSearchRelayList(relays)` exports in init-ndk.mjs.
|
||||
- [ ] **8.3** relays.html: Add a collapsible "Search Relays (kind 10007)"
|
||||
section. Editable list with add/remove. Loads from
|
||||
`getSearchRelayList()` on init, publishes via
|
||||
`publishSearchRelayList()` on change.
|
||||
|
||||
### Phase 9 — relays.html: Blocked relays section (kind 10006) + relayConnectionFilter
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs), [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **9.1** Worker: add fetch/publish for kind 10006. Add a
|
||||
`handleGetBlockedRelayList` handler and a
|
||||
`handlePublishBlockedRelayList` handler.
|
||||
- [ ] **9.2** Worker: set `ndk.relayConnectionFilter` to skip blocked relays.
|
||||
When the blocked relay list is loaded/updated, set
|
||||
`ndk.relayConnectionFilter = (url) => !blockedRelays.has(normalizeRelayUrl(url))`.
|
||||
This makes NDK's outbox tracker skip blocked relays
|
||||
([`www/ndk-core.bundle.js:42835`](www/ndk-core.bundle.js:42835)) and
|
||||
prevents temporary relay connections to blocked relays.
|
||||
- [ ] **9.3** Page API: add `getBlockedRelayList()` and
|
||||
`publishBlockedRelayList(relays)` exports.
|
||||
- [ ] **9.4** relays.html: Add a collapsible "Blocked Relays (kind 10006)"
|
||||
section. Editable list with add/remove.
|
||||
|
||||
### Summary — all phases
|
||||
|
||||
| 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 2–3 | Pending |
|
||||
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 3–5 | Partial (6.1–6.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 1–4 are complete** (deployed v0.7.40–v0.7.41) — they fix the feed
|
||||
outbox routing and the relays.html connection history performance issue.
|
||||
Phases 5–6 (discovered relays visualization) are the next priority. Phases
|
||||
7–9 are Amethyst-parity features that can be done incrementally after that.
|
||||
@@ -163,7 +163,8 @@
|
||||
onUserSettings,
|
||||
ensureMuteListLoaded,
|
||||
isEventMuted,
|
||||
addMute
|
||||
addMute,
|
||||
warmOutbox
|
||||
} from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
@@ -555,6 +556,9 @@ 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);
|
||||
|
||||
// NDK.subscribe already calls outboxTracker.trackUsers internally,
|
||||
// so no explicit warmOutbox call is needed here.
|
||||
|
||||
subscribe(
|
||||
{ kinds: [1], authors, since },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
@@ -577,11 +581,20 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const allAuthors = Array.from(feedPubkeys);
|
||||
|
||||
if (!hasBootstrappedFeed) {
|
||||
// Pre-warm the outbox tracker so NDK knows each follow's write relays
|
||||
// before the short-lived bootstrap fetchEvents calls fire.
|
||||
try {
|
||||
await warmOutbox(followedPubkeys);
|
||||
} catch (e) {
|
||||
console.warn('[feed.html] warmOutbox failed (non-blocking):', e?.message || e);
|
||||
}
|
||||
await bootstrapFeedPosts(allAuthors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newAuthors.length > 0) {
|
||||
// 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
190
www/js/broadcast-relays.mjs
Normal 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');
|
||||
@@ -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') {
|
||||
@@ -433,6 +455,37 @@ function handleWorkerMessage(event) {
|
||||
pending.resolve(message.events || []);
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'warmOutboxResult') {
|
||||
// Handle warmOutbox response — best-effort pre-warm; always resolves.
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(message.requestId);
|
||||
pending.resolve({
|
||||
ok: !!message.ok,
|
||||
count: message.count || 0,
|
||||
skipped: !!message.skipped,
|
||||
error: message.error || null
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'setRelayEventLoggingResult') {
|
||||
// Handle setRelayEventLogging response — fire-and-forget, but resolve
|
||||
// the promise if the caller chose to await it.
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(message.requestId);
|
||||
pending.resolve({
|
||||
ok: !!message.ok,
|
||||
enabled: !!message.enabled,
|
||||
error: message.error || null
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'getDiscoveredRelaysResult') {
|
||||
// Handle getDiscoveredRelays response
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(message.requestId);
|
||||
pending.resolve(message.relays || []);
|
||||
}
|
||||
} else if (message.type === 'fetchCachedProfileResult') {
|
||||
// Handle cached profile response
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
@@ -694,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
|
||||
@@ -763,6 +862,128 @@ export async function ndkFetchEvents(filters) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the NDK outbox tracker for a set of follow pubkeys.
|
||||
*
|
||||
* This proactively resolves each follow's kind 10002 relay list via
|
||||
* ndk.outboxTracker.trackUsers so that subsequent short-lived fetchEvents
|
||||
* subscriptions route to the correct write relays instead of racing the
|
||||
* tracker. Pre-warming is best-effort: the returned promise always resolves
|
||||
* (never rejects). On timeout it resolves with { ok: false, timedOut: true }.
|
||||
*
|
||||
* @param {string[]} pubkeys - Array of hex pubkeys to pre-warm.
|
||||
* @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, timedOut?: boolean, error?: string}>}
|
||||
*/
|
||||
export async function warmOutbox(pubkeys) {
|
||||
if (!ndkWorker) {
|
||||
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
|
||||
}
|
||||
|
||||
const list = Array.isArray(pubkeys) ? pubkeys.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
return { ok: true, count: 0, skipped: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestCounter++;
|
||||
const requestId = `warmOutbox_${Date.now()}_${requestCounter}`;
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject: resolve });
|
||||
|
||||
// Best-effort: resolve (not reject) on timeout.
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve({ ok: false, timedOut: true });
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'warmOutbox',
|
||||
requestId: requestId,
|
||||
pubkeys: list
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle relay event logging in the worker.
|
||||
*
|
||||
* When enabled, the worker broadcasts relayEvent messages to all ports and
|
||||
* persists relay events to IndexedDB. When disabled (default), both are
|
||||
* skipped to avoid cross-page broadcast spam and IndexedDB write contention.
|
||||
* trackRelayRead/trackRelayWrite (relayActivity events) are not affected.
|
||||
*
|
||||
* Fire-and-forget: the returned promise resolves when the worker acknowledges,
|
||||
* but callers may ignore it.
|
||||
*
|
||||
* @param {boolean} enabled - Whether relay event logging should be on.
|
||||
* @returns {Promise<{ok: boolean, enabled: boolean, error?: string}>}
|
||||
*/
|
||||
export function setRelayEventLogging(enabled) {
|
||||
if (!ndkWorker) {
|
||||
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestCounter++;
|
||||
const requestId = `setRelayEventLogging_${Date.now()}_${requestCounter}`;
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject: resolve });
|
||||
|
||||
// Fire-and-forget timeout — resolve gracefully if the worker never
|
||||
// acknowledges (it always should, but be safe).
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve({ ok: false, enabled: !!enabled, error: 'setRelayEventLogging timeout' });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'setRelayEventLogging',
|
||||
requestId: requestId,
|
||||
enabled: !!enabled
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of "discovered" relays currently in the NDK pool that are NOT
|
||||
* part of the user's own kind 10002 relay list. These are temporary outbox
|
||||
* relays NDK connected to in order to fetch events from followed authors who
|
||||
* write to relays the user does not subscribe to.
|
||||
*
|
||||
* Each entry is { url, status, connected, servingPubkeys: string[] }.
|
||||
*
|
||||
* @returns {Promise<Array<{url: string, status: number, connected: boolean, servingPubkeys: string[]}>>}
|
||||
*/
|
||||
export async function getDiscoveredRelays() {
|
||||
if (!ndkWorker) {
|
||||
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestCounter++;
|
||||
const requestId = `getDiscoveredRelays_${Date.now()}_${requestCounter}`;
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject: resolve });
|
||||
|
||||
// Resolve with [] on timeout — this is a read-only diagnostic call.
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve([]);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getDiscoveredRelays',
|
||||
requestId: requestId
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from all relays in parallel
|
||||
* Returns events grouped by relay URL
|
||||
@@ -885,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);
|
||||
@@ -893,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',
|
||||
@@ -918,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);
|
||||
@@ -926,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',
|
||||
|
||||
@@ -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
345
www/js/stego.mjs
Normal 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)));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.38",
|
||||
"VERSION_NUMBER": "0.7.38",
|
||||
"BUILD_DATE": "2026-06-26T11:18:32.425Z"
|
||||
"VERSION": "v0.7.77",
|
||||
"VERSION_NUMBER": "0.7.77",
|
||||
"BUILD_DATE": "2026-06-30T14:21:55.493Z"
|
||||
}
|
||||
|
||||
1872
www/llm-steganography.html
Normal file
1872
www/llm-steganography.html
Normal file
File diff suppressed because it is too large
Load Diff
1143
www/ndk-worker.js
1143
www/ndk-worker.js
File diff suppressed because it is too large
Load Diff
@@ -40,13 +40,19 @@
|
||||
|
||||
#divBackBar,
|
||||
#divHint,
|
||||
#divThread,
|
||||
#divComposer {
|
||||
#divThread {
|
||||
width: 80%;
|
||||
min-width: 300px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
/* #divComposer lives INSIDE #divThread (already constrained to 700px),
|
||||
so it must fill its parent (100%) rather than re-applying 80%
|
||||
which would shrink it to 80% of 700 = 560px. */
|
||||
#divComposer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#divBackBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
388
www/relays.html
388
www/relays.html
@@ -7,6 +7,7 @@
|
||||
<title>RELAYS</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
<link rel="stylesheet" href="./css/sidenav-sections.css" />
|
||||
|
||||
<!-- Initialize theme BEFORE any components load -->
|
||||
<script>
|
||||
@@ -256,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>
|
||||
@@ -332,13 +337,24 @@
|
||||
/* ================================================================
|
||||
IMPORTS
|
||||
================================================================ */
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents } 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...`);
|
||||
@@ -370,6 +386,7 @@ const versionInfo = await getVersion();
|
||||
let selectedRelayUrl = null; // Relay row currently selected for debug event history
|
||||
const relayEventHistory = new Map(); // relay.url -> [{ timestamp, rawTimestamp, eventType, side, summary }]
|
||||
const relayUiStats = new Map(); // relay.url -> { readCount, writeCount }
|
||||
let connectionHistoryEnabled = false; // Toggled via sidenav; gates live relay event logging
|
||||
|
||||
const RELAY_EVENTS_DB_NAME = 'relay-events';
|
||||
const RELAY_EVENTS_DB_VERSION = 1;
|
||||
@@ -417,8 +434,80 @@ const versionInfo = await getVersion();
|
||||
if (hamburgerInstance) {
|
||||
hamburgerInstance.animateTo('arrow_left');
|
||||
}
|
||||
divSideNavBody.innerHTML = "Relay management options coming soon...";
|
||||
|
||||
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
|
||||
connectionHistoryEnabled = historyEnabled;
|
||||
divSideNavBody.innerHTML = `
|
||||
<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 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';
|
||||
}
|
||||
if (checked) {
|
||||
if (selectedRelayUrl) {
|
||||
loadRelayEventsFromDb(selectedRelayUrl);
|
||||
}
|
||||
renderRelayEventsForSelectedRelay();
|
||||
} else {
|
||||
if (divRelayEvents) {
|
||||
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', {
|
||||
@@ -463,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
|
||||
================================================================ */
|
||||
@@ -942,7 +1184,9 @@ const versionInfo = await getVersion();
|
||||
if (!relayUrl) return;
|
||||
selectedRelayUrl = normalizeRelayUrlForDb(relayUrl);
|
||||
await createRelayTable(currentRelayList);
|
||||
await loadRelayEventsFromDb(selectedRelayUrl);
|
||||
if (connectionHistoryEnabled) {
|
||||
await loadRelayEventsFromDb(selectedRelayUrl);
|
||||
}
|
||||
renderRelayEventsForSelectedRelay();
|
||||
};
|
||||
|
||||
@@ -1267,10 +1511,112 @@ const versionInfo = await getVersion();
|
||||
}
|
||||
|
||||
if (selectedRelayUrl === normalizedRelayUrl) {
|
||||
renderRelayEventsForSelectedRelay();
|
||||
// Incremental append instead of full re-render
|
||||
const sideClass = side === 'client' ? 'client' : 'relay';
|
||||
const label = side === 'client' ? 'CLIENT ➜ RELAY' : 'RELAY ➜ CLIENT';
|
||||
const dataPart = summary ? `\n${summary}` : '';
|
||||
const text = `[${timestamp}] ${label} ${eventType.toUpperCase()}${dataPart}`;
|
||||
const row = document.createElement('div');
|
||||
row.className = `relay-event-row ${sideClass}`;
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'relay-event-bubble';
|
||||
bubble.textContent = text;
|
||||
row.appendChild(bubble);
|
||||
divRelayEvents.appendChild(row);
|
||||
|
||||
// Remove oldest if over 1000
|
||||
while (divRelayEvents.children.length > 1000) {
|
||||
divRelayEvents.removeChild(divRelayEvents.firstChild);
|
||||
}
|
||||
|
||||
divRelayEvents.scrollTop = divRelayEvents.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -1386,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();
|
||||
@@ -1403,6 +1753,7 @@ const versionInfo = await getVersion();
|
||||
// Note: We need to listen on the worker port, not window
|
||||
// This will be set up via a custom event from init-ndk.mjs
|
||||
window.addEventListener('ndkRelayEvent', (event) => {
|
||||
if (!connectionHistoryEnabled) return;
|
||||
const { relayUrl, event: eventType, data, timestamp, side } = event.detail;
|
||||
logRelayEvent(relayUrl, eventType, data, timestamp, side);
|
||||
});
|
||||
@@ -1433,6 +1784,9 @@ const versionInfo = await getVersion();
|
||||
|
||||
await loadCurrentDmInboxRelayList();
|
||||
|
||||
// Initialize discovered relays (outbox) collapsible toggle
|
||||
initDiscoveredRelaysToggle();
|
||||
|
||||
// Get initial relay data
|
||||
await refreshRelayData();
|
||||
|
||||
@@ -1444,7 +1798,27 @@ 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);
|
||||
if (!connectionHistoryEnabled) {
|
||||
const wrap = document.getElementById('divRelayEventsWrap');
|
||||
if (wrap) {
|
||||
wrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the worker from broadcasting relay events when the page is closing
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (connectionHistoryEnabled) {
|
||||
setRelayEventLogging(false);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[relay2.html] Initialization complete');
|
||||
} catch (error) {
|
||||
console.error('[relay2.html] Initialization failed:', error);
|
||||
|
||||
Reference in New Issue
Block a user