Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4abb4bf33c | ||
|
|
7dcfc7c3e3 | ||
|
|
07f5f39f35 | ||
|
|
10ddda633c | ||
|
|
c55a5ebfd8 | ||
|
|
b6e945f8de | ||
|
|
3ec585273f | ||
|
|
0e8229e13e | ||
|
|
230bd70e07 | ||
|
|
c36cb4a0fe | ||
|
|
d6b167a50b | ||
|
|
27f4cd6857 | ||
|
|
28c8109f8a | ||
|
|
00e93b2b2f |
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/
|
||||
|
||||
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.
|
||||
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,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
|
||||
|
||||
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.55",
|
||||
"VERSION_NUMBER": "0.7.55",
|
||||
"BUILD_DATE": "2026-06-27T01:51:10.342Z"
|
||||
"VERSION": "v0.7.69",
|
||||
"VERSION_NUMBER": "0.7.69",
|
||||
"BUILD_DATE": "2026-06-30T13:19:30.001Z"
|
||||
}
|
||||
|
||||
1872
www/llm-steganography.html
Normal file
1872
www/llm-steganography.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -319,6 +319,16 @@ let relayActivityStats = new Map(); // relay.url -> { readCount, writeCount, las
|
||||
let relaysWithListeners = new Set(); // Track which relays have event listeners attached
|
||||
let relayTypes = new Map(); // relay.url -> 'read'|'write'|'both' (from kind 10002)
|
||||
let latestRelayListCreatedAt = 0; // guard against stale kind 10002 events arriving out of order
|
||||
|
||||
// Broadcast relay state (kind 10088 — NIP-51 private list, NIP-44 encrypted content).
|
||||
// Mirrors Amethyst's BroadcastRelayListEvent. Active relays are unioned into every
|
||||
// public publish via handlePublish; skip-marked relays are kept in the list for
|
||||
// visibility but excluded from the publish relay set.
|
||||
let broadcastRelayUrls = new Set(); // active "r" tags from kind 10088
|
||||
let skippedRelayUrls = new Map(); // url -> { reason, timestamp } ("x" tags)
|
||||
let latest10088Event = null; // raw event for update-preserving saves
|
||||
let skipMarkSaveTimer = null; // debounce timer for skip-mark saves
|
||||
let latest10088CreatedAt = 0; // guard against stale kind 10088 events
|
||||
let relayEventsPruneTimer = null;
|
||||
let relayConnectAttemptStartedAt = new Map(); // relay.url -> timestamp ms
|
||||
let relayLastDisconnectMeta = new Map(); // relay.url -> { timestamp, code, reason, wasClean }
|
||||
@@ -1756,6 +1766,273 @@ async function loadWorkerMuteList(pubkey, { reason = 'manual' } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcast relays (kind 10088) — NIP-51 private list, NIP-44 encrypted content.
|
||||
// Active relays ("r" tags) are unioned into every public publish; skip-marked
|
||||
// relays ("x" tags) are kept in the list for visibility but excluded from the
|
||||
// publish relay set. Coexists with Amethyst, which only reads/writes "r" tags.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Kinds that should be broadcast to the user's broadcast relay set. */
|
||||
const BROADCAST_KINDS = new Set([1, 6, 7, 30023, 30024, 30315, 31123, 31124, 10088]);
|
||||
|
||||
/**
|
||||
* Returns true if the given event kind should be unioned with the broadcast
|
||||
* relay set on publish. Mirrors Amethyst's Account.kt:1217 gate — public
|
||||
* posts/reactions/reposts get the broadcast set; DMs, app settings, wallet
|
||||
* events, and gift-wrapped events do NOT.
|
||||
*/
|
||||
function wantsBroadcastRelays(event) {
|
||||
if (!event) return false;
|
||||
return BROADCAST_KINDS.has(Number(event.kind));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the active broadcast relay URLs (r-tags minus x-tag skip-marks).
|
||||
* This is the set that handlePublish unions into the outbox write relays.
|
||||
*/
|
||||
function getActiveBroadcastUrls() {
|
||||
return Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a kind 10088 event into broadcastRelayUrls / skippedRelayUrls.
|
||||
* Public ["r", url] tags are read first, then the NIP-44-encrypted content is
|
||||
* decrypted (reusing the same messageSigner plumbing as the mute list) and the
|
||||
* private tags are merged: ["r", url] → broadcastRelayUrls,
|
||||
* ["x", url, reason, timestamp] → skippedRelayUrls.
|
||||
*
|
||||
* @param {object} event - raw kind 10088 event (NDKEvent or plain object)
|
||||
* @param {string} author - hex pubkey of the author (for NIP-44 decrypt)
|
||||
* @returns {Promise<boolean>} true if decryption succeeded (or was unnecessary)
|
||||
*/
|
||||
async function parse10088Event(event, author) {
|
||||
if (!event) {
|
||||
broadcastRelayUrls = new Set();
|
||||
skippedRelayUrls = new Map();
|
||||
latest10088Event = null;
|
||||
latest10088CreatedAt = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextR = new Set();
|
||||
const nextX = new Map();
|
||||
|
||||
// Public r-tags (rare for kind 10088, but supported by the NIP).
|
||||
for (const tag of (event.tags || [])) {
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
nextR.add(tag[1]);
|
||||
} else if (tag[0] === 'x' && tag[1]) {
|
||||
nextX.set(tag[1], {
|
||||
reason: tag[2] || 'unknown',
|
||||
timestamp: Number(tag[3]) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Private tags live in the NIP-44-encrypted content blob.
|
||||
const encrypted = String(event.content || '').trim();
|
||||
if (encrypted && messageSigner) {
|
||||
try {
|
||||
const decryptMethod = isWorkerNip04Content(encrypted) ? 'decrypt' : 'nip44Decrypt';
|
||||
const decryptResult = await messageSigner.requestFromPage(decryptMethod, {
|
||||
senderPubkey: author,
|
||||
ciphertext: encrypted,
|
||||
});
|
||||
const plaintext = typeof decryptResult === 'string'
|
||||
? decryptResult
|
||||
: String(decryptResult?.plaintext || '');
|
||||
if (plaintext) {
|
||||
const privateTags = JSON.parse(plaintext);
|
||||
if (Array.isArray(privateTags)) {
|
||||
for (const tag of privateTags) {
|
||||
if (!Array.isArray(tag)) continue;
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
nextR.add(tag[1]);
|
||||
} else if (tag[0] === 'x' && tag[1]) {
|
||||
nextX.set(tag[1], {
|
||||
reason: tag[2] || 'unknown',
|
||||
timestamp: Number(tag[3]) || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Worker][broadcast] Failed decrypting kind 10088 content:', error?.message || error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
broadcastRelayUrls = nextR;
|
||||
skippedRelayUrls = nextX;
|
||||
latest10088Event = event;
|
||||
latest10088CreatedAt = Number(event.created_at || 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate broadcast relay state from the user's latest kind 10088 event.
|
||||
* Called once during first init (after fetchUserRelays). Leaves both sets
|
||||
* empty if no event exists or decryption fails.
|
||||
*/
|
||||
async function loadBroadcastRelays(pubkey) {
|
||||
const author = normalizeHexPubkey(pubkey || currentPubkey);
|
||||
if (!author || !ndk) {
|
||||
return { loaded: false, reason: 'missing-author-or-ndk' };
|
||||
}
|
||||
|
||||
try {
|
||||
const filter = { kinds: [10088], authors: [author], limit: 1 };
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
const latest = events && events.size > 0
|
||||
? Array.from(events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||
: null;
|
||||
|
||||
if (!latest) {
|
||||
broadcastRelayUrls = new Set();
|
||||
skippedRelayUrls = new Map();
|
||||
latest10088Event = null;
|
||||
latest10088CreatedAt = 0;
|
||||
console.log('[Worker][broadcast] No kind 10088 event found, broadcast relays empty');
|
||||
return { loaded: true, active: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
await parse10088Event(latest, author);
|
||||
console.log('[Worker][broadcast] Loaded kind 10088', {
|
||||
eventId: latest.id,
|
||||
active: broadcastRelayUrls.size,
|
||||
skipped: skippedRelayUrls.size,
|
||||
});
|
||||
return {
|
||||
loaded: true,
|
||||
active: broadcastRelayUrls.size,
|
||||
skipped: skippedRelayUrls.size,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('[Worker][broadcast] Failed to load kind 10088:', error?.message || error);
|
||||
return { loaded: false, error: error?.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build, sign, and publish a kind 10088 event preserving existing r-tags
|
||||
* (union with `activeUrls`) and all existing x-tags (skip-marks). The private
|
||||
* tags (r + x) are NIP-44-encrypted as the content blob; public tags are left
|
||||
* empty so Amethyst's `create()` pattern is mirrored.
|
||||
*
|
||||
* @param {string[]} activeUrls - the new active r-tag list (unioned with existing)
|
||||
* @returns {Promise<boolean>} true if published successfully
|
||||
*/
|
||||
async function saveBroadcastRelaysInternal(activeUrls) {
|
||||
if (!ndk || !currentPubkey) {
|
||||
console.warn('[Worker][broadcast] Cannot save kind 10088 — ndk/pubkey missing');
|
||||
return false;
|
||||
}
|
||||
|
||||
const NDKEvent = NDKExports?.NDKEvent || NDKExports?.default?.NDKEvent;
|
||||
if (!NDKEvent) {
|
||||
console.warn('[Worker][broadcast] NDKEvent unavailable, cannot save kind 10088');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preserve existing r-tags from the latest event (so we don't clobber
|
||||
// Amethyst's list) and union with the new active list.
|
||||
const existingRTags = latest10088Event
|
||||
? (latest10088Event.tags || []).filter(t => t[0] === 'r' && t[1])
|
||||
: [];
|
||||
const mergedRUrls = new Set([
|
||||
...existingRTags.map(t => t[1]),
|
||||
...(Array.isArray(activeUrls) ? activeUrls : []),
|
||||
]);
|
||||
|
||||
// Preserve existing x-tags (skip-marks) — never touch them on a save.
|
||||
const existingXTags = latest10088Event
|
||||
? (latest10088Event.tags || []).filter(t => t[0] === 'x' && t[1])
|
||||
: [];
|
||||
// Also include any in-memory skip-marks not yet persisted.
|
||||
const mergedXEntries = new Map();
|
||||
for (const tag of existingXTags) {
|
||||
mergedXEntries.set(tag[1], [tag[2] || 'unknown', Number(tag[3]) || 0]);
|
||||
}
|
||||
for (const [url, info] of skippedRelayUrls.entries()) {
|
||||
mergedXEntries.set(url, [info.reason || 'unknown', Number(info.timestamp) || 0]);
|
||||
}
|
||||
|
||||
const privateTags = [
|
||||
...Array.from(mergedRUrls).map(u => ['r', u]),
|
||||
...Array.from(mergedXEntries.entries()).map(([url, [reason, ts]]) => ['x', url, reason, ts]),
|
||||
];
|
||||
|
||||
// NIP-44-encrypt the private tags as the content blob.
|
||||
let encrypted = '';
|
||||
try {
|
||||
const encryptedResp = await messageSigner.requestFromPage('nip44Encrypt', {
|
||||
recipientPubkey: currentPubkey,
|
||||
plaintext: JSON.stringify(privateTags),
|
||||
});
|
||||
encrypted = typeof encryptedResp === 'string' ? encryptedResp : encryptedResp?.ciphertext;
|
||||
} catch (error) {
|
||||
console.warn('[Worker][broadcast] NIP-44 encrypt failed, cannot save kind 10088:', error?.message || error);
|
||||
return false;
|
||||
}
|
||||
if (!encrypted) {
|
||||
console.warn('[Worker][broadcast] Empty ciphertext from nip44Encrypt, aborting save');
|
||||
return false;
|
||||
}
|
||||
|
||||
const event = new NDKEvent(ndk, {
|
||||
kind: 10088,
|
||||
content: encrypted,
|
||||
tags: [], // public tags empty — keep private like Amethyst's create()
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
try {
|
||||
await signEventWithMessageSigner(event);
|
||||
await event.publish();
|
||||
} catch (error) {
|
||||
console.warn('[Worker][broadcast] Failed to sign/publish kind 10088:', error?.message || error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update local state from the freshly published event.
|
||||
latest10088Event = event;
|
||||
latest10088CreatedAt = event.created_at || Math.floor(Date.now() / 1000);
|
||||
broadcastRelayUrls = mergedRUrls;
|
||||
// Refresh skippedRelayUrls from the merged set so newly-added skip-marks persist.
|
||||
const nextSkipped = new Map();
|
||||
for (const [url, [reason, ts]] of mergedXEntries.entries()) {
|
||||
nextSkipped.set(url, { reason, timestamp: ts });
|
||||
}
|
||||
skippedRelayUrls = nextSkipped;
|
||||
|
||||
broadcast({ type: 'broadcastRelaysUpdated' });
|
||||
console.log('[Worker][broadcast] Saved kind 10088', {
|
||||
active: broadcastRelayUrls.size,
|
||||
skipped: skippedRelayUrls.size,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced (5s) persistence of skip-marks to kind 10088. Called by
|
||||
* handlePublish after failure marking. Multiple failures in one publish (or
|
||||
* several publishes close together) produce a single republish.
|
||||
*/
|
||||
function scheduleSkipMarkSave() {
|
||||
if (skipMarkSaveTimer) clearTimeout(skipMarkSaveTimer);
|
||||
skipMarkSaveTimer = setTimeout(async () => {
|
||||
skipMarkSaveTimer = null;
|
||||
try {
|
||||
// Preserve current r-tags; only the x-tags change.
|
||||
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
|
||||
} catch (error) {
|
||||
console.warn('[Worker][broadcast] Debounced skip-mark save failed:', error?.message || error);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function applyLightwalletDeletionEvent(event) {
|
||||
let changed = false;
|
||||
for (const tag of event?.tags || []) {
|
||||
@@ -4291,6 +4568,16 @@ async function handleWalletCreateDeposit(requestId, amount, mint, port) {
|
||||
}
|
||||
|
||||
async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
const startedAt = Date.now();
|
||||
const traceId = `walletPayInvoice:${requestId}`;
|
||||
const log = (phase, extra = null) => {
|
||||
if (extra === null) {
|
||||
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`);
|
||||
} else {
|
||||
console.log(`[Worker] ${traceId} +${Date.now() - startedAt}ms ${phase}`, extra);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await ensureDirectWalletLoaded();
|
||||
|
||||
@@ -4303,6 +4590,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
? [requestedMint, ...knownMints.filter((m) => m !== requestedMint)]
|
||||
: [...knownMints].sort((a, b) => getProofTotal(directProofStore[b]) - getProofTotal(directProofStore[a]));
|
||||
|
||||
log('mint-selection', {
|
||||
requestedMint: requestedMint || null,
|
||||
orderedMints: orderedMints.map((m) => ({ mint: m, proofBalance: getProofTotal(directProofStore[m]) }))
|
||||
});
|
||||
|
||||
let payResult = null;
|
||||
let usedMint = null;
|
||||
let paidAmountSats = 0;
|
||||
@@ -4311,7 +4603,11 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
for (const mintUrl of orderedMints) {
|
||||
try {
|
||||
const proofs = Array.isArray(directProofStore[mintUrl]) ? directProofStore[mintUrl] : [];
|
||||
const proofBalance = getProofTotal(proofs);
|
||||
log('mint attempt:start', { mintUrl, proofBalance, proofCount: proofs.length });
|
||||
|
||||
if (proofs.length === 0) {
|
||||
log('mint attempt:no-proofs', { mintUrl });
|
||||
attemptErrors.push({ mint: mintUrl, message: 'No proofs available for mint' });
|
||||
continue;
|
||||
}
|
||||
@@ -4320,44 +4616,60 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
const meltQuote = await wallet.createMeltQuote(pr);
|
||||
const quoteAmount = Number(meltQuote?.amount || 0);
|
||||
const quoteFeeReserve = Number(meltQuote?.fee_reserve || 0);
|
||||
// The mint's quoted fee_reserve is an estimate; the actual melt fee can be
|
||||
// slightly higher, causing "not enough inputs provided for melt" errors.
|
||||
// Add a safety buffer — any overpayment is returned as change proofs.
|
||||
const FEE_RESERVE_BUFFER_SATS = 10;
|
||||
const required = quoteAmount + quoteFeeReserve;
|
||||
const sendAmount = required + FEE_RESERVE_BUFFER_SATS;
|
||||
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required, sendAmount });
|
||||
|
||||
if (!Number.isFinite(required) || required <= 0) {
|
||||
log('mint attempt:invalid-quote', { mintUrl, required });
|
||||
attemptErrors.push({ mint: mintUrl, message: 'Invalid melt quote amount' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const proofBalance = getProofTotal(proofs);
|
||||
if (proofBalance < required) {
|
||||
if (proofBalance < sendAmount) {
|
||||
log('mint attempt:insufficient', { mintUrl, proofBalance, sendAmount });
|
||||
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const sendResult = await wallet.send(required, proofs);
|
||||
const sendResult = await wallet.send(sendAmount, proofs);
|
||||
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
|
||||
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
|
||||
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length, sendAmount });
|
||||
|
||||
const meltResult = await wallet.meltProofs(meltQuote, sendProofs);
|
||||
const change = Array.isArray(meltResult?.change) ? meltResult.change : [];
|
||||
upsertMintProofs(mintUrl, [...keep, ...change]);
|
||||
|
||||
const changeAmount = getProofTotal(change);
|
||||
const spentAmount = Math.max(0, required - changeAmount);
|
||||
const spentAmount = Math.max(0, sendAmount - changeAmount);
|
||||
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
|
||||
? quoteAmount
|
||||
: spentAmount;
|
||||
|
||||
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, spentAmount, preimage: meltResult?.preimage || null });
|
||||
|
||||
usedMint = mintUrl;
|
||||
payResult = meltResult;
|
||||
break;
|
||||
} catch (error) {
|
||||
log('mint attempt:error', { mintUrl, message: error?.message || String(error) });
|
||||
attemptErrors.push({ mint: mintUrl, message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!payResult || !usedMint) {
|
||||
const detail = attemptErrors.map((entry) => `${entry.mint || 'unknown'}: ${entry.message}`).join(' | ');
|
||||
log('all-mints-failed', { attemptErrors });
|
||||
throw new Error(`Failed to pay invoice. ${detail || 'No mint could complete melt'}`);
|
||||
}
|
||||
|
||||
log('paid', { usedMint, paidAmountSats, preimage: payResult?.preimage || null });
|
||||
|
||||
const tx = appendDirectTransaction({
|
||||
type: 'pay',
|
||||
amount: Number.isFinite(paidAmountSats) && paidAmountSats > 0
|
||||
@@ -4403,6 +4715,7 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
}
|
||||
})();
|
||||
} catch (error) {
|
||||
log('error', { message: error?.message || String(error), stack: error?.stack || null });
|
||||
port.postMessage({ type: 'response', requestId, error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -5181,6 +5494,44 @@ async function handleInit(pubkey, port) {
|
||||
});
|
||||
});
|
||||
|
||||
// Hydrate broadcast relay state (kind 10088) — active r-tags +
|
||||
// skip-marked x-tags. Non-fatal: leaves both sets empty on failure.
|
||||
broadcastStartupStatus('Fetching broadcast relay list (kind 10088)...');
|
||||
try {
|
||||
await loadBroadcastRelays(pubkey);
|
||||
} catch (err) {
|
||||
console.warn('[Worker] Broadcast relay hydration failed, continuing init:', err?.message || err);
|
||||
}
|
||||
|
||||
// Subscribe to kind 10088 for live updates from other tabs/clients
|
||||
// (including Amethyst). Re-parses into broadcastRelayUrls /
|
||||
// skippedRelayUrls and broadcasts broadcastRelaysUpdated to all ports.
|
||||
try {
|
||||
console.log('[Worker] Subscribing to kind 10088 broadcast relay updates for:', pubkey);
|
||||
const broadcastFilter = { kinds: [10088], authors: [pubkey] };
|
||||
const broadcastSub = ndk.subscribe(broadcastFilter, { closeOnEose: false });
|
||||
broadcastSub.on('event', async (event) => {
|
||||
const incomingCreatedAt = event?.created_at || 0;
|
||||
if (incomingCreatedAt < latest10088CreatedAt) {
|
||||
console.log('[Worker] Ignoring stale kind 10088 event:', {
|
||||
incomingCreatedAt,
|
||||
latest10088CreatedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const author = normalizeHexPubkey(pubkey || currentPubkey);
|
||||
const ok = await parse10088Event(event, author);
|
||||
if (!ok) return;
|
||||
console.log('[Worker] Updated broadcast relays from subscription:', {
|
||||
active: broadcastRelayUrls.size,
|
||||
skipped: skippedRelayUrls.size,
|
||||
});
|
||||
broadcast({ type: 'broadcastRelaysUpdated' });
|
||||
});
|
||||
} catch (subErr) {
|
||||
console.warn('[Worker] Kind 10088 subscription failed:', subErr?.message || subErr);
|
||||
}
|
||||
|
||||
// Phase 1 startup downloads for wallet-related events + follows.
|
||||
// Run in background so init does not block UI startup paths (e.g. walletInit).
|
||||
scheduleStartupDownloads(pubkey, 'first-init');
|
||||
@@ -5833,35 +6184,206 @@ async function handlePublish(requestId, event, port) {
|
||||
console.log('[Worker] Requesting signature from page...');
|
||||
await signEventWithMessageSigner(ndkEvent);
|
||||
console.log('[Worker] Event signed, publishing to relays...');
|
||||
|
||||
// Publish to relays
|
||||
const relaySet = await ndkEvent.publish();
|
||||
|
||||
|
||||
// --- Broadcast relay union -------------------------------------------------
|
||||
// If this event kind is broadcast-worthy and the user has active broadcast
|
||||
// relays (kind 10088 r-tags minus x-tag skip-marks), union them with the
|
||||
// user's outbox write relays and publish to the combined set via a
|
||||
// temporary NDKRelaySet. NDK's fromRelayUrls uses pool.useTemporaryRelay
|
||||
// so hundreds of broadcast relays connect transiently without polluting
|
||||
// the read subscription pool.
|
||||
let targetRelaySet = null;
|
||||
const activeBroadcastUrls = getActiveBroadcastUrls();
|
||||
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
|
||||
let outboxWriteUrls = [];
|
||||
|
||||
if (isBroadcast) {
|
||||
outboxWriteUrls = Array.from(relayTypes.entries())
|
||||
.filter(([, t]) => t === 'write' || t === 'both')
|
||||
.map(([url]) => url);
|
||||
const 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)`);
|
||||
} else {
|
||||
console.warn('[Worker] NDKRelaySet.fromRelayUrls unavailable, falling back to default outbox');
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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 all pages via
|
||||
// broadcast() so footers can show "📡 42/150 relays · relay.example.com".
|
||||
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const publishedSoFar = new Set();
|
||||
const failedSoFar = new Set(); // explicit rejections (auth-required, blocked, etc.)
|
||||
const timedOutSoFar = new Set(); // timeouts — NOT skip-marked (could be slow connect)
|
||||
const totalTarget = targetRelaySet && targetRelaySet.relays
|
||||
? targetRelaySet.relays.size
|
||||
: (targetRelaySet ? (targetRelaySet.size || 0) : 0);
|
||||
|
||||
if (isBroadcast && totalTarget > 0) {
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
eventKind: event.kind,
|
||||
eventId: ndkEvent.id || null,
|
||||
phase: 'start',
|
||||
total: totalTarget,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
latestRelay: null,
|
||||
});
|
||||
|
||||
ndkEvent.on('relay:published', (relay) => {
|
||||
const url = relay?.url || String(relay || '');
|
||||
if (!url) return;
|
||||
publishedSoFar.add(url);
|
||||
trackRelayWrite(url);
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size,
|
||||
latestRelay: url,
|
||||
});
|
||||
});
|
||||
|
||||
ndkEvent.on('relay:publish:failed', (relay, err) => {
|
||||
const url = relay?.url || String(relay || '');
|
||||
if (!url) return;
|
||||
const errMsg = err?.message || String(err) || '';
|
||||
// Distinguish timeouts from explicit rejections. Timeouts with
|
||||
// hundreds of relays often just mean the relay was slow to connect,
|
||||
// not that it's broken — so we track them separately and do NOT
|
||||
// skip-mark them. Only explicit rejections go into failedSoFar.
|
||||
const isTimeout = /timeout/i.test(errMsg);
|
||||
if (isTimeout) {
|
||||
timedOutSoFar.add(url);
|
||||
} else {
|
||||
failedSoFar.add(url);
|
||||
}
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size + timedOutSoFar.size,
|
||||
latestRelay: url,
|
||||
latestError: errMsg,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Publish to relays (with broadcast relay set if broadcasting, else default outbox).
|
||||
// When broadcasting to many relays (potentially hundreds), use a much longer
|
||||
// timeout than NDK's default 4400ms — temporary relays need time to establish
|
||||
// WebSocket connections. Use 30s for broadcasts, default for normal publishes.
|
||||
// requiredRelayCount=1 so NDK doesn't throw if only 1 of 600 relays succeeds.
|
||||
const BROADCAST_TIMEOUT_MS = 30000;
|
||||
const publishTimeoutMs = isBroadcast ? BROADCAST_TIMEOUT_MS : undefined;
|
||||
const publishRequiredCount = isBroadcast ? 1 : undefined;
|
||||
|
||||
let relaySet;
|
||||
try {
|
||||
relaySet = await ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount);
|
||||
} catch (publishError) {
|
||||
// NDK throws NDKPublishError if requiredRelayCount isn't met. With
|
||||
// requiredRelayCount=1 this shouldn't happen for broadcasts, but handle
|
||||
// it gracefully — the live progress listeners already captured whatever
|
||||
// succeeded. Log and continue so we still report results to the page.
|
||||
console.warn('[Worker] publish() threw (some relays may still have succeeded):', publishError?.message || publishError);
|
||||
relaySet = null;
|
||||
}
|
||||
|
||||
// Final broadcast progress message with complete results.
|
||||
if (isBroadcast && totalTarget > 0) {
|
||||
const totalFailed = failedSoFar.size + timedOutSoFar.size;
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'done',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: totalFailed,
|
||||
latestRelay: null,
|
||||
});
|
||||
// Clean up listeners so the NDKEvent can be garbage-collected.
|
||||
try {
|
||||
ndkEvent.removeAllListeners('relay:published');
|
||||
ndkEvent.removeAllListeners('relay:publish:failed');
|
||||
} catch (_cleanupErr) {
|
||||
// removeAllListeners may not exist on all EventEmitter impls; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
// Get detailed relay results
|
||||
const relayResults = {
|
||||
successful: [],
|
||||
failed: []
|
||||
};
|
||||
|
||||
|
||||
// Check which relays are in the relay set (successful)
|
||||
if (relaySet && relaySet.size > 0) {
|
||||
for (const relay of relaySet) {
|
||||
relayResults.successful.push(relay.url);
|
||||
// Track relay write activity
|
||||
trackRelayWrite(relay.url);
|
||||
// Track relay write activity (also tracked live above for broadcasts)
|
||||
if (!isBroadcast) trackRelayWrite(relay.url);
|
||||
}
|
||||
}
|
||||
|
||||
// Check which connected relays didn't accept it
|
||||
for (const relay of ndk.pool.relays.values()) {
|
||||
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
|
||||
relayResults.failed.push(relay.url);
|
||||
|
||||
// For broadcast publishes, use the live-tracked sets from the
|
||||
// relay:published / relay:publish:failed events as the source of truth
|
||||
// (they cover temporary relays that may not be in ndk.pool). For normal
|
||||
// publishes, fall back to the pool-scan logic.
|
||||
if (isBroadcast) {
|
||||
relayResults.successful = Array.from(publishedSoFar);
|
||||
// Include both explicit rejections and timeouts in the failed list
|
||||
// for display purposes, but keep them distinguishable for skip-marking.
|
||||
relayResults.failed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
|
||||
} else {
|
||||
// Check which connected relays didn't accept it
|
||||
for (const relay of ndk.pool.relays.values()) {
|
||||
if (relay.status >= 5 && !relayResults.successful.includes(relay.url)) {
|
||||
relayResults.failed.push(relay.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Worker] ✅ Published to relays:', relayResults.successful);
|
||||
console.log('[Worker] ❌ Failed relays:', relayResults.failed);
|
||||
|
||||
|
||||
console.log('[Worker] ✅ Published to relays:', relayResults.successful.length, relayResults.successful.slice(0, 10), relayResults.successful.length > 10 ? `... +${relayResults.successful.length - 10} more` : '');
|
||||
console.log('[Worker] ❌ Failed relays:', relayResults.failed.length);
|
||||
|
||||
// --- Failure marking for broadcast relays ---------------------------------
|
||||
// Persist skip-marks ONLY for broadcast relays that EXPLICITLY rejected
|
||||
// the publish (auth-required, blocked, invalid, etc.). We do NOT skip-mark
|
||||
// relays that timed out — with hundreds of relays, a timeout often means
|
||||
// the relay was slow to connect, not that it's broken. The relay:publish:failed
|
||||
// listener above separates timeouts (timedOutSoFar) from explicit rejections
|
||||
// (failedSoFar) by checking the error message for "timeout".
|
||||
if (isBroadcast) {
|
||||
const newSkips = Array.from(failedSoFar)
|
||||
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
|
||||
if (newSkips.length > 0) {
|
||||
for (const url of newSkips) {
|
||||
skippedRelayUrls.set(url, {
|
||||
reason: 'publish-rejected',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
scheduleSkipMarkSave();
|
||||
console.log(`[Worker] Skip-marked ${newSkips.length} rejected broadcast relays ` +
|
||||
`(${timedOutSoFar.size} timeouts excluded from skip-marking)`);
|
||||
} else if (timedOutSoFar.size > 0) {
|
||||
console.log(`[Worker] ${timedOutSoFar.size} broadcast relays timed out (not skip-marked — will retry next publish)`);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a kind 10002 (relay list) event, update stored relay types
|
||||
// AND sync the live relay pool so newly-added relays are actually connectable.
|
||||
if (event.kind === 10002) {
|
||||
@@ -7018,6 +7540,113 @@ self.onconnect = (event) => {
|
||||
void handleGetDiscoveredRelays(requestId, port);
|
||||
break;
|
||||
|
||||
case 'getBroadcastRelays':
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: {
|
||||
active: Array.from(broadcastRelayUrls),
|
||||
skipped: Array.from(skippedRelayUrls.entries()).map(
|
||||
([url, info]) => ({ url, ...info })
|
||||
),
|
||||
},
|
||||
});
|
||||
break;
|
||||
|
||||
case 'saveBroadcastRelays': {
|
||||
const urlsToSave = Array.isArray(e.data.urls) ? e.data.urls : [];
|
||||
void saveBroadcastRelaysInternal(urlsToSave)
|
||||
.then((ok) => {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: { success: !!ok },
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'seedBroadcastRelays': {
|
||||
// One-click "Add all discovered relays": union the discovered
|
||||
// relays cache with the existing active r-tags, excluding any
|
||||
// skip-marked relays, then persist.
|
||||
try {
|
||||
const discovered = Array.isArray(discoveredRelaysCache) ? discoveredRelaysCache : [];
|
||||
const discoveredUrls = discovered
|
||||
.map(r => r?.url)
|
||||
.filter(Boolean);
|
||||
const newUrls = discoveredUrls.filter(u =>
|
||||
!broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
|
||||
);
|
||||
if (newUrls.length === 0) {
|
||||
port.postMessage({ type: 'response', requestId, data: { added: 0 } });
|
||||
break;
|
||||
}
|
||||
for (const u of newUrls) broadcastRelayUrls.add(u);
|
||||
void saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls))
|
||||
.then(() => {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: { added: newUrls.length },
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'unskipBroadcastRelay': {
|
||||
const unskipUrl = e.data.url;
|
||||
if (unskipUrl) {
|
||||
skippedRelayUrls.delete(unskipUrl);
|
||||
// No need to add to broadcastRelayUrls — skipped relays stay
|
||||
// in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls.
|
||||
void saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls))
|
||||
.then(() => {
|
||||
broadcast({ type: 'broadcastRelaysUpdated' });
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: { success: true },
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
error: error?.message || String(error),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
error: 'Missing url for unskipBroadcastRelay',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'setActiveSigningPort':
|
||||
activeSigningPort = port;
|
||||
console.log('[Worker] Active signing port set explicitly by page');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
206
www/relays.html
206
www/relays.html
@@ -342,8 +342,19 @@
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
|
||||
import {
|
||||
loadBroadcastRelays,
|
||||
addBroadcastRelay,
|
||||
removeBroadcastRelay,
|
||||
seedFromDiscovered,
|
||||
unskipRelay,
|
||||
onBroadcastRelaysChanged,
|
||||
getActiveBroadcastRelays,
|
||||
getSkippedBroadcastRelays,
|
||||
} from './js/broadcast-relays.mjs';
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
console.log(`[relays.html ${VERSION}] Loading...`);
|
||||
@@ -430,6 +441,33 @@ const versionInfo = await getVersion();
|
||||
<span style="flex:1;">Show connection history</span>
|
||||
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
|
||||
</div>
|
||||
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
|
||||
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
|
||||
<span style="flex:1;">Broadcast Relays
|
||||
(<span id="spanBroadcastActive">0</span> active,
|
||||
<span id="spanBroadcastSkipped">0</span> skipped)
|
||||
</span>
|
||||
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
|
||||
</div>
|
||||
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
|
||||
style="flex:1;font-size:80%;padding:2px 4px;" />
|
||||
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
|
||||
</div>
|
||||
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
|
||||
Add all discovered relays
|
||||
</button>
|
||||
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
|
||||
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
|
||||
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
|
||||
</div>
|
||||
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
|
||||
Save to Nostr (kind 10088)
|
||||
</button>
|
||||
<div id="divBroadcastStatus" style="font-size:75%;margin-top:4px;min-height:1em;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up the connection history toggle
|
||||
@@ -462,7 +500,14 @@ const versionInfo = await getVersion();
|
||||
});
|
||||
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
|
||||
}
|
||||
|
||||
|
||||
// Wire up the Broadcast Relays collapsible section.
|
||||
initBroadcastRelaysSection();
|
||||
|
||||
// Refresh broadcast relay data from the worker every time the sidenav
|
||||
// opens so the user sees the freshest active/skipped lists.
|
||||
loadBroadcastRelays();
|
||||
|
||||
// Initialize version bar buttons when sidenav opens (lazy load)
|
||||
if (!logoutHamburger) {
|
||||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||||
@@ -507,6 +552,159 @@ const versionInfo = await getVersion();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
BROADCAST RELAYS SECTION (kind 10088)
|
||||
================================================================ */
|
||||
let broadcastRelaysSectionWired = false;
|
||||
|
||||
function initBroadcastRelaysSection() {
|
||||
// Toggle header click → show/hide content.
|
||||
const header = document.getElementById('divBroadcastRelaysHeader');
|
||||
const content = document.getElementById('divBroadcastRelaysContent');
|
||||
const icon = document.getElementById('divBroadcastToggleIcon');
|
||||
if (header && content) {
|
||||
header.addEventListener('click', () => {
|
||||
const isOpen = content.style.display !== 'none';
|
||||
content.style.display = isOpen ? 'none' : 'block';
|
||||
if (icon) icon.textContent = isOpen ? '▸' : '▾';
|
||||
});
|
||||
}
|
||||
|
||||
// Add button.
|
||||
const btnAdd = document.getElementById('btnBroadcastAdd');
|
||||
const txtAdd = document.getElementById('txtBroadcastAdd');
|
||||
if (btnAdd && txtAdd) {
|
||||
const doAdd = async () => {
|
||||
const url = txtAdd.value.trim();
|
||||
if (!url) return;
|
||||
txtAdd.value = '';
|
||||
setBroadcastStatus('Adding…');
|
||||
await addBroadcastRelay(url);
|
||||
setBroadcastStatus('Added');
|
||||
setTimeout(() => setBroadcastStatus(''), 2000);
|
||||
};
|
||||
btnAdd.addEventListener('click', doAdd);
|
||||
txtAdd.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doAdd(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Seed from discovered relays button.
|
||||
const btnSeed = document.getElementById('btnBroadcastSeed');
|
||||
if (btnSeed) {
|
||||
btnSeed.addEventListener('click', async () => {
|
||||
setBroadcastStatus('Seeding from discovered relays…');
|
||||
try {
|
||||
const added = await seedFromDiscovered();
|
||||
setBroadcastStatus(added > 0
|
||||
? `Added ${added} relay${added === 1 ? '' : 's'}`
|
||||
: 'No new relays to add');
|
||||
} catch (err) {
|
||||
setBroadcastStatus('Seed failed: ' + (err?.message || err));
|
||||
}
|
||||
setTimeout(() => setBroadcastStatus(''), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// Save button (the worker auto-saves on add/remove, but this gives the
|
||||
// user an explicit "flush to Nostr" action as the plan specifies).
|
||||
const btnSave = document.getElementById('btnBroadcastSave');
|
||||
if (btnSave) {
|
||||
btnSave.addEventListener('click', async () => {
|
||||
setBroadcastStatus('Saving to Nostr (kind 10088)…');
|
||||
try {
|
||||
// Re-save the current active list to force a fresh kind 10088
|
||||
// publish (the worker deduplicates/merges with existing r-tags).
|
||||
const { saveBroadcastRelays } = await import('./js/init-ndk.mjs');
|
||||
await saveBroadcastRelays(getActiveBroadcastRelays());
|
||||
setBroadcastStatus('Saved');
|
||||
} catch (err) {
|
||||
setBroadcastStatus('Save failed: ' + (err?.message || err));
|
||||
}
|
||||
setTimeout(() => setBroadcastStatus(''), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to changes from the wrapper module (re-render).
|
||||
if (!broadcastRelaysSectionWired) {
|
||||
broadcastRelaysSectionWired = true;
|
||||
onBroadcastRelaysChanged(() => renderBroadcastRelaysSection());
|
||||
|
||||
// Cross-tab / cross-client sync: the worker forwards kind 10088
|
||||
// subscription updates as this window event.
|
||||
window.addEventListener('ndkBroadcastRelaysUpdated', () => {
|
||||
loadBroadcastRelays();
|
||||
});
|
||||
}
|
||||
|
||||
renderBroadcastRelaysSection();
|
||||
}
|
||||
|
||||
function setBroadcastStatus(text) {
|
||||
const el = document.getElementById('divBroadcastStatus');
|
||||
if (el) el.textContent = text || '';
|
||||
}
|
||||
|
||||
function renderBroadcastRelaysSection() {
|
||||
const spanActive = document.getElementById('spanBroadcastActive');
|
||||
const spanSkipped = document.getElementById('spanBroadcastSkipped');
|
||||
const listEl = document.getElementById('divBroadcastList');
|
||||
const skippedEl = document.getElementById('divBroadcastSkippedList');
|
||||
if (!listEl || !skippedEl) return;
|
||||
|
||||
const activeUrls = getActiveBroadcastRelays();
|
||||
const skipped = getSkippedBroadcastRelays();
|
||||
|
||||
if (spanActive) spanActive.textContent = String(activeUrls.length);
|
||||
if (spanSkipped) spanSkipped.textContent = String(skipped.length);
|
||||
|
||||
// Active list
|
||||
if (activeUrls.length === 0) {
|
||||
listEl.innerHTML = '<div style="font-size:75%;opacity:0.6;">No active broadcast relays.</div>';
|
||||
} else {
|
||||
listEl.innerHTML = activeUrls.map((url) => `
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
|
||||
<span style="flex:1;word-break:break-all;">${escapeHtml(url)}</span>
|
||||
<button data-broadcast-remove="${escapeHtml(url)}" style="font-size:75%;padding:0 4px;">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
listEl.querySelectorAll('[data-broadcast-remove]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => removeBroadcastRelay(btn.getAttribute('data-broadcast-remove')));
|
||||
});
|
||||
}
|
||||
|
||||
// Skipped list
|
||||
if (skipped.length === 0) {
|
||||
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div><div style="font-size:75%;">None skipped.</div>';
|
||||
} else {
|
||||
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>' +
|
||||
skipped.map((item) => {
|
||||
const ts = item.timestamp
|
||||
? new Date(item.timestamp * 1000).toLocaleString()
|
||||
: '';
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
|
||||
<span style="flex:1;word-break:break-all;">
|
||||
${escapeHtml(item.url)}
|
||||
<div style="font-size:70%;opacity:0.8;">${escapeHtml(item.reason || 'failed')}${ts ? ' · ' + escapeHtml(ts) : ''}</div>
|
||||
</span>
|
||||
<button data-broadcast-unskip="${escapeHtml(item.url)}" style="font-size:75%;padding:0 4px;">Unskip</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
skippedEl.querySelectorAll('[data-broadcast-unskip]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => unskipRelay(btn.getAttribute('data-broadcast-unskip')));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const map = { '&': 38, '<': 60, '>': 62, '"': 34, "'": 39 };
|
||||
return String(s || '').replace(/[&<>"']/g, (c) =>
|
||||
'&#' + map[c] + ';'
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
RELAY TABLE FUNCTIONS
|
||||
================================================================ */
|
||||
@@ -1534,6 +1732,10 @@ const versionInfo = await getVersion();
|
||||
|
||||
// Initialize NDK (handles authentication automatically)
|
||||
await initNDKPage();
|
||||
|
||||
// Load broadcast relays from the worker so the sidenav shows fresh
|
||||
// data the first time it is opened.
|
||||
loadBroadcastRelays();
|
||||
|
||||
// Get authenticated pubkey
|
||||
currentPubkey = await getPubkey();
|
||||
|
||||
Reference in New Issue
Block a user