Compare commits

...

24 Commits

Author SHA1 Message Date
Laan Tungir
10ddda633c Add broadcast relays feature: kind 10088 list with always-on union, persistent skip-marks, live progress in post.html footer, sidenav management UI on relays.html 2026-06-30 07:47:22 -04:00
Laan Tungir
c55a5ebfd8 Self-host GPT-2 model files to fix CORS over Tor/onion origins 2026-06-28 20:46:57 -04:00
Laan Tungir
b6e945f8de Use locally-hosted GPT-2 model to fix CORS over Tor/onion origins 2026-06-28 20:45:25 -04:00
Laan Tungir
3ec585273f Change 'post on Primal' to 'post on Nostr' in inspiration FAQ 2026-06-28 20:32:50 -04:00
Laan Tungir
0e8229e13e Update inspiration FAQ to reference waxwing's Primal post and the Meteor paper 2026-06-28 20:30:26 -04:00
Laan Tungir
230bd70e07 Change all muted/dimmed text on stego page to regular primary color 2026-06-28 20:27:13 -04:00
Laan Tungir
c36cb4a0fe Replace simple example FAQ with detailed toy-model walkthrough from llm_steganography_explained.md 2026-06-28 20:25:51 -04:00
Laan Tungir
d6b167a50b Expand How It Works into FAQ section with collapsible questions 2026-06-28 20:23:01 -04:00
Laan Tungir
27f4cd6857 Set llm-steganography page to no-login (auth mode none) by default 2026-06-28 20:18:18 -04:00
Laan Tungir
28c8109f8a Add LLM Steganography demo page with GPT-2-based half-splitting entropy coding 2026-06-28 20:17:04 -04:00
Laan Tungir
00e93b2b2f This is a meaningful git commit message 2026-06-27 08:17:42 -04:00
Laan Tungir
2334f742cf Add kind 0 cache query as fallback for profile names in discovered relays — Dexie profiles table may not have all follows, so also query kind 0 events from NDK cache. Add debug log for name resolution count. 2026-06-26 21:51:10 -04:00
Laan Tungir
8c456ec522 Fix discovered relays cache invalidation: add version suffix to cache key so servingNames data is rebuilt after code change 2026-06-26 21:33:34 -04:00
Laan Tungir
e734f33542 Display usernames instead of pubkeys in discovered relays Follows column — query Dexie profiles table for displayName/name, fall back to truncated pubkey if no profile cached 2026-06-26 20:20:37 -04:00
Laan Tungir
6ad2d37a23 Redesign handleGetDiscoveredRelays: use Dexie cache for follows' kind 10002 events instead of ephemeral outbox tracker. Stable relay→follows mapping that only changes when contact list changes. Live connection status from pool. Cache result in worker to avoid re-querying Dexie on every expand. 2026-06-26 13:43:40 -04:00
Laan Tungir
28ce7bce1b Fix discovered relays table: filter out rows with no serving pubkeys, rename 'Sample Follows' to 'Follows', show all follows (not just 3), fix layout wrapping with overflow-x auto and max-height scroll 2026-06-26 13:29:49 -04:00
Laan Tungir
894a163111 Fix relay disappearing: make port.onmessage synchronous (all handlers fire-and-forget). Fix discovered relays serving data: replace broken LRU cache iteration with kind 3 cache query + per-pubkey trackerData.get(). Remove debug logging. 2026-06-26 10:52:01 -04:00
Laan Tungir
efe9d977cd Make warmOutbox handler non-blocking (fire-and-forget) in worker dispatch — trackUsers involves network I/O that was blocking the entire message dispatch loop, starving getRelayData/getRelayStats 2026-06-26 10:27:42 -04:00
Laan Tungir
bff0bca411 Remove redundant warmOutbox calls from ensureLiveFeedSubscription and newAuthors path — NDK.subscribe already calls trackUsers internally, so only the pre-bootstrap warmOutbox is needed. This reduces the outbox event burst that was starving the worker message queue. 2026-06-26 10:17:56 -04:00
Laan Tungir
fee3ffc764 Increase getRelayData/getRelayStats timeout from 5s to 15s — worker is busy processing outbox/NDK events from feed.html warmOutbox, causing message queue delays 2026-06-26 10:14:21 -04:00
Laan Tungir
d49a3e800f Add debug logging to getDiscoveredRelays handler to diagnose why Serving/Sample Follows columns are empty 2026-06-26 10:11:55 -04:00
Laan Tungir
9d9d0f88f5 Remove discovered relays auto-refresh interval — fetch only on manual expand click to prevent worker message queue starvation 2026-06-26 10:08:13 -04:00
Laan Tungir
b11161862a Fix relays.html relay data timeout: separate discovered relays refresh to 15s interval only when expanded, make worker getDiscoveredRelays handler non-blocking (fire-and-forget) to prevent message queue starvation 2026-06-26 09:54:18 -04:00
Laan Tungir
fe35b2dfa1 Phase 5: Add discovered relays (outbox) table to relays.html — collapsible section showing temporary outbox relays with connection status, serving follow count, and sample npubs 2026-06-26 09:46:35 -04:00
11 changed files with 4306 additions and 159 deletions

3
.gitignore vendored
View File

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

View File

@@ -0,0 +1,713 @@
# Broadcast Relays Feature
## Goal
Allow the user to publish posts to **many more relays** than their standard
NIP-65 outbox — potentially hundreds — by maintaining a separate "broadcast
relay list" that is **unioned into every public publish** automatically.
This mirrors Amethyst's `BroadcastRelayListEvent` (NIP-51 kind 10088) and uses
NDK's `NDKRelaySet.fromRelayUrls()` primitive, which the worker already
exercises in `handlePublishRawToRelay`.
## Design decisions (confirmed with user)
1. **Always-on union** — broadcast relays are automatically unioned into every
public publish. No per-post toggle. (Matches Amethyst's default.)
2. **Manage on `relays.html`** — a new "Broadcast Relays" section alongside
"Discovered Relays" for add/remove/save.
3. **One-click seed from discovered relays** — a button on `relays.html` bulk-
adds the discovered-relays URLs (from follows' kind 10002) to the 10088
list. User stays in control of when it happens.
4. **Persistent skip-marks in kind 10088** — when a relay fails to publish
(auth-required, unreachable, etc.), it stays in the list but is marked with
a separate `["x", url]` tag so we skip it on future publishes. See
"Skip-mark coexistence with Amethyst" below.
## Background — what each codebase already does
### Amethyst (`~/lt/amethyst`)
- [`BroadcastRelayListEvent.kt`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt:43)
defines **kind 10088** — a NIP-51 `PrivateTagArrayEvent`. Relay URLs live as
`["r", url]` tags inside a NIP-44-encrypted `content` blob (private list),
with an optional public-tag variant. It is replaceable per pubkey
(`d = ""`).
- [`AccountOutboxRelayState.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt:41)
**unions** the broadcast set into the outbox:
`nip65Outbox + privateOutBox + localRelays + broadcastRelays`.
- [`Account.kt:1266`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1266)
gates inclusion with `wantsBroadcastRelays(event)` — public posts/reactions/
reposts get the broadcast set; drafts, app settings, bookmarks, channels with
their own relays, and DMs do **not**.
- [`Account.kt:1715`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1715)
`sendLiterallyEverywhere()` is a separate "publish to everything" path used
only for profile/identity bootstrap events — **not** what we want for posts.
### This client (`/home/user/lt/client`)
- [`publishEvent`](www/js/init-ndk.mjs:629) → worker
[`handlePublish`](www/ndk-worker.js:5810) → `ndkEvent.publish()` with **no
relaySet** → NDK outbox routing sends only to the user's kind 10002 write
relays.
- [`publishRawEventToRelay`](www/js/init-ndk.mjs:688) → worker
[`handlePublishRawToRelay`](www/ndk-worker.js:5983) already demonstrates the
exact NDK primitive we need:
```js
NDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk)
await ndkEvent.publish(targetRelaySet)
```
NDK's `fromRelayUrls` (see `ndk-core.bundle.js:9245`) accepts an **array**
and uses **temporary relays** (`pool.useTemporaryRelay`) so hundreds of URLs
work without permanently polluting the connection pool.
- [`mute-list.mjs`](www/js/mute-list.mjs:41) establishes the
NIP-44-encrypted-private-list pattern we will mirror:
`configureMuteList({ nip44Encrypt, nip44Decrypt, publishEvent, getPubkey })`.
- The post composer ([`post-composer.mjs:740`](www/js/post-composer.mjs:740))
calls `onSubmit(text, {attachments})` → pages call
`publishEvent({kind:1,...})`. No composer change is needed for the always-on
design.
- No kind 10088 / broadcast-relay concept exists yet.
## Skip-mark coexistence with Amethyst
Amethyst's [`RelayTag`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt:28)
only matches `tag[0] == "r"` and reads `tag[1]` — it **ignores** any third
element. Its `updateRelayList` calls `tags.remove(RelayTag::match)` which only
strips `r` tags, **preserving** any non-`r` tags through Amethyst's updates.
This means we can safely store skip-marks in the **same kind 10088 event** as
a separate tag type that Amethyst will never touch:
```
// Active broadcast relay (Amethyst reads this):
["r", "wss://relay.example"]
// Skip-marked relay (Amethyst ignores — not an "r" tag):
["x", "wss://private.relay.example", "auth-required", "1700000000"]
// ^ ^ ^ ^
// | url reason timestamp of failure
```
**Rules:**
- Our client reads **both** `r` and `x` tags from kind 10088.
- `r` tags = active broadcast relays (unioned into publishes).
- `x` tags = skip-marked relays (kept in the list for visibility, but excluded
from the publish relay set).
- When Amethyst updates the event, it only touches `r` tags — our `x` tags
survive untouched.
- When our client updates the event, we preserve all existing `r` tags (so we
don't clobber Amethyst's list) and only add/remove `x` tags.
- A skip-marked relay can be manually un-skipped from the UI (moves it back to
`r`), or auto-un-skipped if a retry succeeds.
**Why not a third element on the `r` tag?** Amethyst's `RelayTag.parse()`
ignores `tag[2]`, so `["r", url, "skipped"]` would technically survive parsing.
But Amethyst's `updateRelayList` does `tags.remove(RelayTag::match)` which
removes **all** `r` tags before re-adding the new list — so any third-element
annotation would be **wiped** on Amethyst's next save. A separate `x` tag is
the only safe coexistence strategy.
## Auto-seed from discovered relays
Your client already computes the perfect seed source:
[`handleGetDiscoveredRelays`](www/ndk-worker.js:6458) builds a
`relayServesPubkeys` map from all follows' kind 10002 `write`/`both` relays,
cached in the module-level `discoveredRelaysCache` variable, keyed by the
user's kind 3 contact list. The "Discovered Relays (Outbox)" section on
`relays.html` already displays this cache.
**No duplication** — the "Add all discovered relays" button reads the **same
`discoveredRelaysCache`** variable already populated by
`handleGetDiscoveredRelays`. No second query, no second cache. The two
features serve different purposes:
- **Discovered Relays** = read-only display of relays your follows use (for
awareness, cached from their kind 10002 events).
- **Broadcast Relays** = your persistent kind 10088 list of relays YOU will
publish to (user-curated, unioned into every publish).
The seed button is the one-way bridge: discovered → broadcast. The button
will:
1. Call the existing `getDiscoveredRelays` worker handler to get the cached
list of `{url, servingPubkeys, ...}` objects.
2. Extract just the URLs.
3. Union them with the existing `r`-tag broadcast relays (skip any already
present, skip any in the `x`-tag skip-mark set).
4. Save the merged list to kind 10088.
This is a **one-click, user-initiated** action — no automatic background
seeding. The user can re-run it after following new people to pick up their
relays.
## Architecture
```mermaid
flowchart LR
subgraph Page
PC[post-composer.mjs] -->|publishEvent kind:1| INIT[init-ndk.mjs]
REL[relays.html] -->|saveBroadcastRelays / seedFromDiscovered| INIT
end
INIT -->|publish / saveBroadcastRelays| W[ndk-worker.js]
subgraph Worker
W --> HP[handlePublish]
HP --> WBR{wantsBroadcastRelays?}
WBR -->|yes| FILTER[active r-tags minus x-tag skips]
WBR -->|no| DEFAULT[ndkEvent.publish default]
FILTER --> UNION[union outbox + active broadcast urls]
UNION --> RS[NDKRelaySet.fromRelayUrls]
RS --> PUB[ndkEvent.publish relaySet]
PUB --> MARK[on failure: add x-tag skip-mark to 10088]
end
subgraph Nostr
PUB --> R10002[Kind 10002 write relays]
PUB --> R10088[Kind 10088 broadcast relays - hundreds]
end
W -.->|subscribe kind 10088| R10088
R10088 -.->|live update| W
```
## Implementation plan
### 1. Worker: broadcast-relay state + hydration (`www/ndk-worker.js`)
- Add module-level state:
```js
let broadcastRelayUrls = new Set(); // active "r" tags
let skippedRelayUrls = new Map(); // url -> { reason, timestamp } ("x" tags)
let latest10088Event = null; // raw event for update-preserving saves
```
- On first init (after `fetchUserRelays`), fetch the user's kind 10088:
```js
const filter = { kinds: [10088], authors: [pubkey], limit: 1 };
const events = await ndk.fetchEvents(filter);
// pick newest event → store as latest10088Event
// Parse public tags: ["r", url] → broadcastRelayUrls
// NIP-44-decrypt content → parse private tags:
// ["r", url] → broadcastRelayUrls (Amethyst stores relays privately)
// ["x", url, reason, timestamp] → skippedRelayUrls
```
Reuse the existing `nip44Decrypt` plumbing the worker already has for the
mute list. If decryption fails or no event exists, leave both sets empty.
- Subscribe to kind 10088 for the pubkey so live updates from other tabs/
clients (including Amethyst) refresh both sets and broadcast a
`broadcastRelaysUpdated` message to all ports (mirrors the existing
`relay-types-updated` pattern at `ndk-worker.js:5177`).
- Add a helper `getActiveBroadcastUrls()` that returns
`Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u))` —
this is what `handlePublish` will use.
### 2. Worker: `wantsBroadcastRelays` gate + union in `handlePublish`
Add a helper near `handlePublish` (`ndk-worker.js:5810`):
```js
function wantsBroadcastRelays(event) {
// Public, broadcast-worthy kinds. Mirror Amethyst's Account.kt:1217.
const BROADCAST_KINDS = new Set([
1, // text note
6, // repost
7, // reaction
30023, // long-form article
30024, // draft long-form
30315, // status
31123, // public skill
31124, // private skill (still public event)
10088, // broadcast relay list itself
]);
// Explicitly EXCLUDE: 4 (DM), 10002 (relay list), 10050 (DM inbox),
// 30078 (app settings), 7375/17375 (wallet), 1059/13/14 (gift wrap/seal).
return BROADCAST_KINDS.has(Number(event.kind));
}
```
Modify `handlePublish` after signing, before `ndkEvent.publish()`:
```js
let targetRelaySet = null;
const activeBroadcastUrls = getActiveBroadcastUrls(); // r-tags minus x-tag skips
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
if (isBroadcast) {
// Union the user's normal outbox write relays with the active broadcast set.
const outboxWriteUrls = Array.from(relayTypes.entries())
.filter(([_, t]) => t === 'write' || t === 'both')
.map(([url]) => url);
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
if (NDKRelaySet?.fromRelayUrls) {
targetRelaySet = NDKRelaySet.fromRelayUrls(allUrls, ndk);
console.log(`[Worker] Broadcasting to ${allUrls.length} relays ` +
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
`${skippedRelayUrls.size} skipped)`);
}
}
// --- Live broadcast progress streaming ---
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per relay
// as each completes (see ndk-core.bundle.js:8530 and :8552). We attach
// listeners BEFORE publish() and stream progress to the page via broadcast().
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
const publishedSoFar = new Set();
const failedSoFar = new Set();
const totalTarget = targetRelaySet ? targetRelaySet.relays.size : 0;
if (isBroadcast && totalTarget > 0) {
// Announce the broadcast session to all pages.
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
eventKind: event.kind,
eventId: ndkEvent.id || null,
phase: 'start',
total: totalTarget,
successful: 0,
failed: 0,
latestRelay: null,
});
ndkEvent.on('relay:published', (relay) => {
publishedSoFar.add(relay.url);
trackRelayWrite(relay.url);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: relay.url,
});
});
ndkEvent.on('relay:publish:failed', (relay, err) => {
failedSoFar.add(relay.url);
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'progress',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: relay.url,
latestError: err?.message || String(err),
});
});
}
const relaySet = await ndkEvent.publish(targetRelaySet);
// Final broadcast progress message with complete results.
if (isBroadcast && totalTarget > 0) {
broadcast({
type: 'broadcastProgress',
sessionId: publishSessionId,
phase: 'done',
total: totalTarget,
successful: publishedSoFar.size,
failed: failedSoFar.size,
latestRelay: null,
});
// Clean up listeners.
ndkEvent.removeAllListeners('relay:published');
ndkEvent.removeAllListeners('relay:publish:failed');
}
```
`NDKRelaySet.fromRelayUrls` uses `pool.useTemporaryRelay` for URLs not already
in the pool, so hundreds of broadcast relays connect transiently for the
publish and do not interfere with the user's read subscriptions.
**Failure marking** — after `ndkEvent.publish()` returns, the existing code at
[`ndk-worker.js:5856`](www/ndk-worker.js:5856) already computes
`relayResults.failed` (connected relays that didn't accept the event). Extend
it to persist skip-marks for broadcast relays that failed:
```js
// After the existing relayResults.failed computation:
if (isBroadcast) {
const newSkips = relayResults.failed
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
if (newSkips.length > 0) {
for (const url of newSkips) {
skippedRelayUrls.set(url, {
reason: 'publish-failed',
timestamp: Math.floor(Date.now() / 1000),
});
}
// Persist the updated skip-marks to kind 10088 (debounced — see step 3).
scheduleSkipMarkSave();
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays`);
}
}
```
The skip-mark save is **debounced** (e.g. 5s) so a single post that fails on
20 relays produces one kind 10088 republish, not 20. The save preserves all
existing `r` tags and only updates the `x` tags (see step 3).
### 3. Worker: `getBroadcastRelays` + `saveBroadcastRelays` + `seedBroadcastRelays` + skip-mark handlers
Add new message cases in the worker's `switch` (near `ndk-worker.js:6980`):
- `getBroadcastRelays` → respond with:
```js
{
active: Array.from(broadcastRelayUrls),
skipped: Array.from(skippedRelayUrls.entries()).map(([url, info]) => ({ url, ...info })),
}
```
- `saveBroadcastRelays` → build/update a kind 10088 event. **Critical: preserve
existing `r` tags and all `x` tags** so we coexist with Amethyst:
```js
// Start from latest10088Event if we have one (preserve Amethyst's r-tags
// that the user might not have in our local set yet). Otherwise start fresh.
const existingRTags = latest10088Event
? latest10088Event.tags.filter(t => t[0] === 'r')
: [];
// Merge: union the user's new urls with existing r-tags.
const mergedRUrls = new Set([
...existingRTags.map(t => t[1]),
...urls, // the new active list from the UI
]);
// Preserve existing x-tags (skip-marks) — never touch them on a user save.
const existingXTags = latest10088Event
? latest10088Event.tags.filter(t => t[0] === 'x')
: [];
// NIP-44-encrypt the private tags (r + x) as the content.
const privateTags = [
...Array.from(mergedRUrls).map(u => ['r', u]),
...existingXTags,
];
const encrypted = await nip44Encrypt(currentPubkey, JSON.stringify(privateTags));
const event = {
kind: 10088,
content: encrypted,
tags: [], // public tags empty — keep private like Amethyst's create()
created_at: Math.floor(Date.now() / 1000),
};
// sign via signEventWithMessageSigner, then publish (default outbox).
// Update broadcastRelayUrls locally + broadcast broadcastRelaysUpdated.
```
- `seedBroadcastRelays` → one-click "Add all discovered relays":
```js
// Reuse the discovered-relays cache (handleGetDiscoveredRelays logic).
// Get the cached list of {url, ...} objects.
const discovered = discoveredRelaysCache || [];
const discoveredUrls = discovered.map(r => r.url).filter(Boolean);
// Union with existing active r-tags, excluding skip-marked relays.
const newUrls = discoveredUrls.filter(u =>
!broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
);
if (newUrls.length === 0) {
port.postMessage({ type: 'response', requestId, data: { added: 0 } });
return;
}
// Merge into broadcastRelayUrls and save.
for (const u of newUrls) broadcastRelayUrls.add(u);
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
port.postMessage({ type: 'response', requestId, data: { added: newUrls.length } });
```
- `unskipBroadcastRelay` → move a relay from the `x`-tag skip set back to
active (user clicked "Retry" / "Unskip" in the UI):
```js
skippedRelayUrls.delete(url);
// No need to add to broadcastRelayUrls — it's already there (we keep
// skipped relays in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls).
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
```
- `scheduleSkipMarkSave()` (internal, debounced) — called by `handlePublish`
after failure marking. Same save logic as `saveBroadcastRelays` but only
updates the `x` tags, preserving the current `r` tags. Debounce with a 5s
timer so multiple failures in one publish produce one republish.
Reuse the existing `signEventWithMessageSigner` and the existing
`nip44Encrypt`/`nip44Decrypt` helpers the worker already wires for the mute
list and NIP-17 messaging.
### 4. `init-ndk.mjs` exports + progress forwarding
Add alongside [`publishEvent`](www/js/init-ndk.mjs:629):
```js
export async function getBroadcastRelays() { /* sendWorkerRequest → {active, skipped} */ }
export async function saveBroadcastRelays(urls) { /* sendWorkerRequest */ }
export async function seedBroadcastRelays() { /* sendWorkerRequest → {added} */ }
export async function unskipBroadcastRelay(url) { /* sendWorkerRequest */ }
```
Forward two new worker message types to window events:
1. `broadcastRelaysUpdated` →
`window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated', {detail}))`
(same pattern as `ndkRelayActivity` at `ndk-worker.js:2673`).
2. `broadcastProgress` →
`window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {detail}))`
so pages can show live publish progress. The detail payload:
```js
{
sessionId, phase, // 'start' | 'progress' | 'done'
eventKind, eventId,
total, // total relays being published to
successful, // count so far
failed, // count so far
latestRelay, // URL of the most recent relay to respond
latestError, // error message if latestRelay failed
}
```
### 5. New module `www/js/broadcast-relays.mjs`
Thin wrapper mirroring [`mute-list.mjs`](www/js/mute-list.mjs:41):
```js
import {
getBroadcastRelays, saveBroadcastRelays,
seedBroadcastRelays, unskipBroadcastRelay, getPubkey
} from './init-ndk.mjs';
let active = new Set();
let skipped = new Map(); // url -> { reason, timestamp }
const listeners = new Set();
export async function loadBroadcastRelays() {
const { active: a, skipped: s } = await getBroadcastRelays();
active = new Set(a);
skipped = new Map(s.map(item => [item.url, item]));
notify();
}
export function getActiveBroadcastRelays() { return Array.from(active); }
export function getSkippedBroadcastRelays() { return Array.from(skipped.values()); }
export function isBroadcastRelay(url) { return active.has(url); }
export async function addBroadcastRelay(url) {
active.add(normalize(url));
await saveBroadcastRelays(Array.from(active));
notify();
}
export async function removeBroadcastRelay(url) {
active.delete(normalize(url));
await saveBroadcastRelays(Array.from(active));
notify();
}
export async function seedFromDiscovered() {
const { added } = await seedBroadcastRelays();
await loadBroadcastRelays(); // refresh from worker
return added;
}
export async function unskipRelay(url) {
await unskipBroadcastRelay(url);
await loadBroadcastRelays();
}
export function onBroadcastRelaysChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); }
function notify() { listeners.forEach(cb => cb({ active: Array.from(active), skipped: Array.from(skipped.values()) })); }
```
### 6. `relays.html` sidenav — broadcast relays management UI
The broadcast relays management UI lives in the **sidenav** below the "Show
connection history" toggle, not in the main body. The sidenav body is rendered
in `openNav()` at [`relays.html:428`](www/relays.html:428).
Extend the `divSideNavBody.innerHTML` template to add a broadcast relays
section below the connection history toggle:
```html
<div id="divRelaySettings" style="..."> <!-- existing: Show connection history -->
...
</div>
<!-- NEW: Broadcast relays section -->
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
<span style="flex:1;">Broadcast Relays
(<span id="spanBroadcastActive">0</span> active,
<span id="spanBroadcastSkipped">0</span> skipped)
</span>
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
</div>
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
style="flex:1;font-size:80%;padding:2px 4px;" />
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
</div>
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
Add all discovered relays
</button>
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
</div>
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
Save to Nostr (kind 10088)
</button>
</div>
</div>
```
Wire it with `broadcast-relays.mjs`:
- On `openNav()`: `loadBroadcastRelays()` → render active + skipped lists +
update count badges.
- Toggle header click → show/hide `divBroadcastRelaysContent`.
- Add button → `addBroadcastRelay(url)` → re-render.
- "Add all discovered relays" button → `seedFromDiscovered()` → show
"Added N relays" toast → re-render.
- Each active row has a remove button → `removeBroadcastRelay(url)`.
- Each skipped row shows the failure reason + timestamp, and an "Unskip"
button → `unskipRelay(url)` → moves it back to active.
- `onBroadcastRelaysChanged` → re-render + update count badges.
- Listen for `ndkBroadcastRelaysUpdated` window event for cross-tab sync.
Styling: reuse the existing sidenav font-size (80%) and `var(--primary-color)`
tokens so it matches the "Show connection history" toggle above it. Skipped
relays render with reduced opacity to distinguish them from active relays.
### 7. Live cross-tab sync
The worker's kind 10088 subscription (step 1) updates `broadcastRelayUrls` and
broadcasts `broadcastRelaysUpdated` to all ports. `init-ndk.mjs` forwards this
to `window.dispatchEvent`. Any open `relays.html` re-renders; any pending
publish in any tab automatically uses the freshest relay set.
### 8. `post.html` footer — live broadcast progress display
**Remove the comments toggle button** from the footer center section
([`post.html:160-162`](www/post.html:160)):
```html
<!-- REMOVE: -->
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
</div>
```
Also remove the comments toggle event listener at
[`post.html:813-817`](www/post.html:813) and the
`getCommentsVisible`/`setCommentsVisible` calls. Comments are no longer active
on this page.
**Replace with a broadcast progress display:**
```html
<div id="divFooterCenter" class="divFooterBox">
<span id="spanBroadcastStatus"></span>
</div>
```
Wire it to the `ndkBroadcastProgress` window event:
```js
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
// Show the running total + the latest relay that responded.
// Truncate the relay URL for the footer's limited width.
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
spanBroadcastStatus.textContent =
`📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
} else if (d.phase === 'done') {
spanBroadcastStatus.textContent =
`✅ ${d.successful}/${d.total} relays` +
(d.failed > 0 ? ` (${d.failed} failed)` : '');
// Clear after 10 seconds so the footer doesn't show stale info forever.
setTimeout(() => {
if (spanBroadcastStatus.textContent.startsWith('✅')) {
spanBroadcastStatus.textContent = '';
}
}, 10000);
}
});
```
The display shows:
- **`start`**: "Broadcasting to N relays…"
- **`progress`**: "📡 42/150 relays · relay.example.com" — the count
continuously increments as each relay responds, with the latest relay's
hostname trailing.
- **`done`**: "✅ 148/150 relays (2 failed)" — final result, auto-clears after
10s.
This is a **reusable function** — the `ndkBroadcastProgress` event fires for
every broadcast publish from any page. Other pages (feed.html, post-feed.html)
can add the same listener if they want to display broadcast progress in their
own footers later.
## Files touched
| File | Change |
|------|--------|
| [`www/ndk-worker.js`](www/ndk-worker.js) | +state (active + skipped), +hydration from kind 10088, +subscription, +`wantsBroadcastRelays`, +union in `handlePublish` with skip filtering, +live progress streaming via `relay:published`/`relay:publish:failed` events, +failure marking, +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` handlers, +debounced skip-mark save |
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` exports, +`ndkBroadcastRelaysUpdated` + `ndkBroadcastProgress` window event forwarding |
| [`www/js/broadcast-relays.mjs`](www/js/broadcast-relays.mjs) | **NEW** — load/save/add/remove/seed/unskip/subscribe wrapper |
| [`www/relays.html`](www/relays.html) | +Broadcast Relays management UI in sidenav below "Show connection history" toggle (active list, skipped list, add, seed-from-discovered, unskip, save) + wiring |
| [`www/post.html`](www/post.html) | Remove comments toggle button + listener, add broadcast progress display in footer center section wired to `ndkBroadcastProgress` |
No changes to `post-composer.mjs` or any page that calls `publishEvent` — the
union is transparent and always-on at the worker layer.
## Kinds reference
- **10088** — Broadcast Relay List (NIP-51 private list, NIP-44 encrypted
content, replaceable `d=""`). Defined by Amethyst's quartz.
- `["r", url]` tags = active broadcast relays (read by both our client and
Amethyst).
- `["x", url, reason, timestamp]` tags = skip-marked relays (read only by
our client; Amethyst's `RelayTag` ignores non-`r` tags).
- **10002** — NIP-65 Relay List (unchanged; still the user's standard
outbox/inbox).
- **1, 6, 7, 30023, 30315, 31123, 31124** — public event kinds that get the
broadcast union.
## Testing
1. Open `relays.html`, add 3-5 broadcast relays manually, click Save.
2. Verify the kind 10088 event appears in the event-management page (kind
10088 row) and that its content is NIP-44-encrypted.
3. Click "Add all discovered relays" → confirm N relays added from follows'
kind 10002 lists.
4. Post a kind 1 from `post.html`.
5. **Footer progress display**: watch the footer center section — it should
show "Broadcasting to N relays…", then continuously update
"📡 42/150 relays · relay.example.com" as each relay responds, then settle
on "✅ 148/150 relays (2 failed)" and auto-clear after 10s.
6. Check the worker console log: `[Worker] Broadcasting to N relays (X
broadcast + Y outbox, Z skipped)`.
7. Confirm the post lands on the broadcast relays (query one via
`publishRawEventToRelay`-style fetch or an external tool like `nak`).
8. Confirm a kind 4 DM or kind 30078 settings save does **not** go to
broadcast relays (check the log lacks the "Broadcasting to" line, and the
footer shows no broadcast progress).
9. Confirm the old "Comments" toggle button is gone from the `post.html`
footer and no longer appears.
10. **Skip-mark test**: add a relay that will reject the publish (e.g. a
private/auth-required relay). Post a kind 1. Confirm:
- The publish log shows the relay in `failed`.
- The worker logs `Skip-marked N failed broadcast relays`.
- The kind 10088 event is republished with an `["x", url, ...]` tag.
- In the `relays.html` sidenav, the relay appears in the "Skipped" section
with the failure reason.
- A subsequent kind 1 publish does **not** attempt that relay (count in
log drops by 1).
11. **Unskip test**: click "Unskip" on a skipped relay → confirm it moves back
to active and is included in the next publish.
12. **Amethyst coexistence test**: if Amethyst is available, edit the
broadcast relay list in Amethyst → confirm our client's `relays.html`
sidenav updates live (kind 10088 subscription). Then edit in our client →
confirm Amethyst still shows its `r`-tag relays (our `x` tags don't
interfere).
13. Open a second tab, edit the broadcast list in the sidenav, confirm the
first tab's `relays.html` sidenav updates live.

View File

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

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

@@ -0,0 +1,190 @@
/**
* Broadcast Relays Module (NIP-51 kind:10088)
*
* Thin wrapper around the worker-side broadcast relay state exposed via
* init-ndk.mjs. Mirrors the singleton/listener pattern of mute-list.mjs but
* delegates all persistence (NIP-44 encryption, kind 10088 publish, skip-mark
* coexistence with Amethyst) to the worker.
*
* Usage:
* import {
* loadBroadcastRelays, getActiveBroadcastRelays, getSkippedBroadcastRelays,
* addBroadcastRelay, removeBroadcastRelay, seedFromDiscovered,
* unskipRelay, onBroadcastRelaysChanged
* } from './broadcast-relays.mjs';
* await loadBroadcastRelays();
*/
import {
getBroadcastRelays,
saveBroadcastRelays,
seedBroadcastRelays,
unskipBroadcastRelay,
} from './init-ndk.mjs';
// ── Internal state ───────────────────────────────────────────────────────────
/** Set of active broadcast relay URLs (kind 10088 "r" tags). */
let active = new Set();
/** Map<url, { reason, timestamp }> of skip-marked relays (kind 10088 "x" tags). */
let skipped = new Map();
/** Change listeners. */
const listeners = new Set();
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Normalize a relay URL: lowercase host, ensure wss:// or ws:// prefix, strip
* trailing slash. Returns null if the input is not a usable URL.
*/
function normalizeRelayUrl(url) {
if (!url) return null;
let s = String(url).trim();
if (!s) return null;
if (!/^wss?:\/\//i.test(s)) {
// Default to wss:// when the user typed a bare host.
s = 'wss://' + s;
}
// Split scheme vs rest so we only lowercase the scheme/host portion.
const match = s.match(/^(wss?:\/\/)([^/]+)(.*)$/i);
if (!match) return null;
const scheme = match[1].toLowerCase();
const host = match[2].toLowerCase();
let path = match[3] || '';
// Strip trailing slash but keep root "/" paths intentional.
if (path.length > 0 && path.endsWith('/')) {
path = path.replace(/\/+$/, '');
}
return scheme + host + path;
}
// ── Load ─────────────────────────────────────────────────────────────────────
/**
* Fetch the current broadcast relay state from the worker and refresh local
* caches. Safe to call repeatedly (e.g. on sidenav open or cross-tab update).
*/
export async function loadBroadcastRelays() {
try {
const result = await getBroadcastRelays();
active = new Set(Array.isArray(result?.active) ? result.active : []);
const skippedArr = Array.isArray(result?.skipped) ? result.skipped : [];
skipped = new Map(skippedArr.map((item) => [item.url, item]));
notify();
} catch (err) {
console.warn('[broadcast-relays] loadBroadcastRelays failed:', err?.message || err);
}
}
// ── Read ─────────────────────────────────────────────────────────────────────
/** Return an array of active broadcast relay URLs. */
export function getActiveBroadcastRelays() {
return Array.from(active);
}
/** Return an array of { url, reason, timestamp } for skip-marked relays. */
export function getSkippedBroadcastRelays() {
return Array.from(skipped.values());
}
/** Check whether a URL is in the active broadcast set. */
export function isBroadcastRelay(url) {
if (!url) return false;
return active.has(url);
}
// ── Mutate ───────────────────────────────────────────────────────────────────
/**
* Add a relay URL to the active broadcast set and persist via the worker.
* The URL is normalized before being stored.
*/
export async function addBroadcastRelay(url) {
const normalized = normalizeRelayUrl(url);
if (!normalized) {
console.warn('[broadcast-relays] addBroadcastRelay: invalid url', url);
return;
}
if (active.has(normalized)) return;
active.add(normalized);
try {
await saveBroadcastRelays(Array.from(active));
} catch (err) {
console.error('[broadcast-relays] addBroadcastRelay save failed:', err?.message || err);
}
notify();
}
/**
* Remove a relay URL from the active broadcast set and persist via the worker.
*/
export async function removeBroadcastRelay(url) {
if (!active.has(url)) return;
active.delete(url);
try {
await saveBroadcastRelays(Array.from(active));
} catch (err) {
console.error('[broadcast-relays] removeBroadcastRelay save failed:', err?.message || err);
}
notify();
}
/**
* One-click seed: add all discovered relays (from follows' kind 10002 lists)
* to the broadcast set. Returns the number of newly added relays.
*/
export async function seedFromDiscovered() {
try {
const result = await seedBroadcastRelays();
await loadBroadcastRelays(); // refresh from worker
return result?.added || 0;
} catch (err) {
console.error('[broadcast-relays] seedFromDiscovered failed:', err?.message || err);
return 0;
}
}
/**
* Remove a relay from the skip-mark set so it is included in future publishes.
*/
export async function unskipRelay(url) {
if (!url) return;
try {
await unskipBroadcastRelay(url);
await loadBroadcastRelays();
} catch (err) {
console.error('[broadcast-relays] unskipRelay failed:', err?.message || err);
}
}
// ── Events ───────────────────────────────────────────────────────────────────
/**
* Register a callback that fires whenever the active/skipped sets change.
* @param {Function} callback
* @returns {Function} unsubscribe
*/
export function onBroadcastRelaysChanged(callback) {
if (typeof callback !== 'function') return () => {};
listeners.add(callback);
return () => listeners.delete(callback);
}
function notify() {
const snapshot = {
active: Array.from(active),
skipped: Array.from(skipped.values()),
};
for (const cb of listeners) {
try {
cb(snapshot);
} catch (err) {
console.warn('[broadcast-relays] Listener error:', err);
}
}
}
console.log('[broadcast-relays] Module loaded');

View File

@@ -373,6 +373,27 @@ 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,
}
}));
} else if (message.type === 'startupStatus') {
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
} else if (message.type === 'relayEvent') {
@@ -725,6 +746,52 @@ export function sendNip17Message(recipientPubkey, content) {
);
}
// ---------------------------------------------------------------------------
// Broadcast relays (kind 10088) — thin wrappers over sendWorkerRequest.
// The worker owns the kind 10088 state (active r-tags + skip-marked x-tags)
// and unions the active set into every public publish automatically.
// ---------------------------------------------------------------------------
/** Returns { active: string[], skipped: { url, reason, timestamp }[] } */
export function getBroadcastRelays() {
return sendWorkerRequest(
'getBroadcastRelays',
{},
15000,
'getBroadcastRelays timeout'
);
}
/** Save a new active broadcast relay list (unioned with existing r-tags). */
export function saveBroadcastRelays(urls) {
return sendWorkerRequest(
'saveBroadcastRelays',
{ urls: Array.isArray(urls) ? urls : [] },
30000,
'saveBroadcastRelays timeout'
);
}
/** One-click seed: add all discovered relays to the broadcast list. Returns { added } */
export function seedBroadcastRelays() {
return sendWorkerRequest(
'seedBroadcastRelays',
{},
30000,
'seedBroadcastRelays timeout'
);
}
/** Remove a relay from the skip-mark set so it is included in future publishes. */
export function unskipBroadcastRelay(url) {
return sendWorkerRequest(
'unskipBroadcastRelay',
{ url: String(url || '') },
30000,
'unskipBroadcastRelay timeout'
);
}
/**
* Fetch events from a single relay
* Returns events from that specific relay
@@ -1038,7 +1105,7 @@ export async function getRelayData() {
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
@@ -1046,7 +1113,7 @@ export async function getRelayData() {
console.warn('[init-ndk] Get relay data timeout, returning empty array');
resolve([]);
}
}, 5000);
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayData',
@@ -1071,7 +1138,7 @@ export async function getRelayStats() {
pendingRequests.set(requestId, { resolve, reject });
// Set timeout
// Set timeout (15s — worker may be busy processing outbox/NDK events)
setTimeout(() => {
if (pendingRequests.has(requestId)) {
pendingRequests.delete(requestId);
@@ -1079,7 +1146,7 @@ export async function getRelayStats() {
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
resolve({});
}
}, 5000);
}, 15000);
ndkWorker.port.postMessage({
type: 'getRelayStats',

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

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

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.42",
"VERSION_NUMBER": "0.7.42",
"BUILD_DATE": "2026-06-26T13:44:56.791Z"
"VERSION": "v0.7.66",
"VERSION_NUMBER": "0.7.66",
"BUILD_DATE": "2026-06-30T11:47:22.403Z"
}

1872
www/llm-steganography.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -158,7 +158,7 @@
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="commentsToggleButton">Comments</button>
<span id="spanBroadcastStatus"></span>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
@@ -810,18 +810,38 @@ import { initPostCards } from './js/post-interactions2.mjs';
document.getElementById('logoutButton')?.addEventListener('click', logout);
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
const nextVisible = !cards.getCommentsVisible();
cards.setCommentsVisible(nextVisible);
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
});
btnSeeMore.addEventListener('click', () => {
displayCount += SEE_MORE_INCREMENT;
renderFeed();
});
await initNDKPage();
// Live broadcast progress display in the footer center section.
// The worker streams per-relay publish results via this window event
// (see plans/broadcast-relays-feature.md §8).
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
window.addEventListener('ndkBroadcastProgress', (event) => {
const d = event.detail;
if (!d || !spanBroadcastStatus) return;
if (d.phase === 'start') {
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
} else if (d.phase === 'progress') {
const shortRelay = d.latestRelay
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
: '';
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)` : '');
setTimeout(() => {
if (spanBroadcastStatus.textContent.startsWith('✅')) {
spanBroadcastStatus.textContent = '';
}
}, 10000);
}
});
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);

View File

@@ -257,6 +257,10 @@
================================================================ -->
<div id="divBody" class="clsBodyFull">
<div id="divRelays">Loading relays...</div>
<div id="divDiscoveredRelaysWrap" style="width:100%;margin:10px 0 0 0;overflow-x:auto;">
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
<div id="divDiscoveredRelays" style="display:none;max-height:300px;overflow-y:auto;"></div>
</div>
<div id="divRelayEventsWrap">
<div id="divRelayEvents" class="relay-events-stream"></div>
</div>
@@ -333,13 +337,24 @@
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging } from './js/init-ndk.mjs';
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import {
loadBroadcastRelays,
addBroadcastRelay,
removeBroadcastRelay,
seedFromDiscovered,
unskipRelay,
onBroadcastRelaysChanged,
getActiveBroadcastRelays,
getSkippedBroadcastRelays,
} from './js/broadcast-relays.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[relays.html ${VERSION}] Loading...`);
@@ -426,6 +441,33 @@ const versionInfo = await getVersion();
<span style="flex:1;">Show connection history</span>
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
</div>
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
<span style="flex:1;">Broadcast Relays
(<span id="spanBroadcastActive">0</span> active,
<span id="spanBroadcastSkipped">0</span> skipped)
</span>
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
</div>
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
<div style="display:flex;gap:4px;margin-bottom:6px;">
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
style="flex:1;font-size:80%;padding:2px 4px;" />
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
</div>
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
Add all discovered relays
</button>
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
</div>
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
Save to Nostr (kind 10088)
</button>
<div id="divBroadcastStatus" style="font-size:75%;margin-top:4px;min-height:1em;"></div>
</div>
</div>
`;
// Wire up the connection history toggle
@@ -458,7 +500,14 @@ const versionInfo = await getVersion();
});
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
}
// Wire up the Broadcast Relays collapsible section.
initBroadcastRelaysSection();
// Refresh broadcast relay data from the worker every time the sidenav
// opens so the user sees the freshest active/skipped lists.
loadBroadcastRelays();
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
@@ -503,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
================================================================ */
@@ -1331,6 +1533,90 @@ const versionInfo = await getVersion();
}
};
const shortNpub = (hex) => {
if (!hex || hex.length < 16) return hex || '-';
return `${hex.slice(0, 8)}${hex.slice(-4)}`;
};
let discoveredRelaysExpanded = false;
const renderDiscoveredRelays = (relays) => {
const container = document.getElementById('divDiscoveredRelays');
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!container || !title) return;
if (!discoveredRelaysExpanded) {
container.style.display = 'none';
title.textContent = `▸ Discovered Relays (Outbox)${relays.length > 0 ? ` (${relays.length})` : ''}`;
return;
}
title.textContent = `▾ Discovered Relays (Outbox) (${relays.length})`;
container.style.display = '';
if (relays.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
// Filter out relays with no serving pubkeys — if no follows use this
// relay, there's no reason to show it in the discovered relays list.
const relaysWithFollows = relays.filter(r =>
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
);
if (relaysWithFollows.length === 0) {
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. Load the feed page to populate outbox relays from your follows.</div>';
return;
}
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
html += '<thead><tr>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Follows</th>';
html += '</tr></thead><tbody>';
for (const relay of relaysWithFollows) {
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
? relay.servingNames.join(', ')
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.map(shortNpub).join(', ') : '-');
const rowClass = relay.connected ? 'tblRowConnected' : '';
html += `<tr class="${rowClass}">`;
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;word-break:break-all;">${followsList}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
};
const refreshDiscoveredRelays = async () => {
try {
const relays = await getDiscoveredRelays();
renderDiscoveredRelays(Array.isArray(relays) ? relays : []);
} catch (error) {
console.warn('[relays.html] Failed to fetch discovered relays:', error?.message || error);
renderDiscoveredRelays([]);
}
};
const initDiscoveredRelaysToggle = () => {
const title = document.getElementById('divDiscoveredRelaysTitle');
if (!title) return;
title.addEventListener('click', () => {
discoveredRelaysExpanded = !discoveredRelaysExpanded;
refreshDiscoveredRelays();
});
};
const refreshRelayData = async () => {
console.log('[relay2.html] Refreshing relay data...');
try {
@@ -1446,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();
@@ -1494,6 +1784,9 @@ const versionInfo = await getVersion();
await loadCurrentDmInboxRelayList();
// Initialize discovered relays (outbox) collapsible toggle
initDiscoveredRelaysToggle();
// Get initial relay data
await refreshRelayData();
@@ -1506,6 +1799,9 @@ const versionInfo = await getVersion();
// Refresh relay table every 5 seconds
setInterval(refreshRelayData, 5000);
// Discovered relays are fetched only on manual expand click — no
// auto-refresh interval, to avoid blocking the worker's message queue.
// Sync connection history toggle state from localStorage
connectionHistoryEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
setRelayEventLogging(connectionHistoryEnabled);