Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27f4cd6857 | ||
|
|
28c8109f8a | ||
|
|
00e93b2b2f | ||
|
|
2334f742cf | ||
|
|
8c456ec522 | ||
|
|
e734f33542 | ||
|
|
6ad2d37a23 | ||
|
|
28ce7bce1b | ||
|
|
894a163111 | ||
|
|
efe9d977cd | ||
|
|
bff0bca411 | ||
|
|
fee3ffc764 | ||
|
|
d49a3e800f | ||
|
|
9d9d0f88f5 | ||
|
|
b11161862a | ||
|
|
fe35b2dfa1 | ||
|
|
ae67e65969 |
@@ -150,53 +150,53 @@ the Amethyst-parity relay management features into one coherent sequence.
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
- [ ] **1.1** Add `handleWarmOutbox(requestId, pubkeys, port)` near
|
||||
- [x] **1.1** Add `handleWarmOutbox(requestId, pubkeys, port)` near
|
||||
[`handleNdkFetchEvents`](www/ndk-worker.js:6316). It should:
|
||||
- Guard `if (!ndk?.outboxTracker) { respond empty ok }`.
|
||||
- `await ndk.outboxTracker.trackUsers(pubkeys)`.
|
||||
- Respond `{ type: 'warmOutboxResult', requestId, ok: true, count: pubkeys.length }`.
|
||||
- Wrap in try/catch; never reject (pre-warming is best-effort).
|
||||
|
||||
- [ ] **1.2** Wire the `warmOutbox` message type in the dispatch switch near
|
||||
- [x] **1.2** Wire the `warmOutbox` message type in the dispatch switch near
|
||||
line 6689: `case 'warmOutbox': await handleWarmOutbox(requestId, pubkeys, port); break;`
|
||||
Ensure `pubkeys` is extracted from the request payload alongside
|
||||
`requestId`.
|
||||
|
||||
- [ ] **1.3** Add a `relayEventLoggingEnabled` flag (default `false`) and a
|
||||
- [x] **1.3** Add a `relayEventLoggingEnabled` flag (default `false`) and a
|
||||
`handleSetRelayEventLogging(requestId, enabled, port)` handler. When
|
||||
`false`, [`broadcastRelayEvent`](www/ndk-worker.js:917) skips the
|
||||
`broadcast()` call AND [`logRelayEvent`](www/ndk-worker.js:899) skips
|
||||
`writeRelayEventToDb`. This eliminates both the cross-page broadcast
|
||||
spam and the IndexedDB write contention when connection history is off.
|
||||
|
||||
- [ ] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
|
||||
- [x] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
|
||||
switch.
|
||||
|
||||
- [ ] **1.5** Add `handleGetDiscoveredRelays(requestId, port)` that returns
|
||||
- [x] **1.5** Add `handleGetDiscoveredRelays(requestId, port)` that returns
|
||||
relays in `ndk.pool.relays` that are *not* in `relayTypes` (your kind
|
||||
10002 list). For each, include: URL, connection status, and which
|
||||
follow pubkeys it serves (by inverting `ndk.outboxTracker.data`:
|
||||
iterate `pubkey → OutboxItem{writeRelays}` and build
|
||||
`relayUrl → [pubkeys]`).
|
||||
|
||||
- [ ] **1.6** Wire the `getDiscoveredRelays` message type in the dispatch
|
||||
- [x] **1.6** Wire the `getDiscoveredRelays` message type in the dispatch
|
||||
switch.
|
||||
|
||||
### Phase 2 — Page API: add `warmOutbox`, `setRelayEventLogging`, `getDiscoveredRelays` exports
|
||||
|
||||
**File:** [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
|
||||
|
||||
- [ ] **2.1** Add `export function warmOutbox(pubkeys)` modeled on
|
||||
- [x] **2.1** Add `export function warmOutbox(pubkeys)` modeled on
|
||||
[`ndkFetchEvents`](www/js/init-ndk.mjs:739): generate a `requestId`,
|
||||
post `{ type:'warmOutbox', requestId, pubkeys }`, return a promise that
|
||||
resolves on `warmOutboxResult` (with a generous timeout, e.g. 15s, that
|
||||
resolves rather than rejects — pre-warm is best-effort).
|
||||
|
||||
- [ ] **2.2** Add `export function setRelayEventLogging(enabled)`: post
|
||||
- [x] **2.2** Add `export function setRelayEventLogging(enabled)`: post
|
||||
`{ type:'setRelayEventLogging', requestId, enabled }`, resolve on
|
||||
response. Fire-and-forget is fine (no need to await).
|
||||
|
||||
- [ ] **2.3** Add `export async function getDiscoveredRelays()`: post
|
||||
- [x] **2.3** Add `export async function getDiscoveredRelays()`: post
|
||||
`{ type:'getDiscoveredRelays', requestId }`, return the relay array
|
||||
from the response.
|
||||
|
||||
@@ -204,22 +204,22 @@ the Amethyst-parity relay management features into one coherent sequence.
|
||||
|
||||
**File:** [`www/feed.html`](www/feed.html)
|
||||
|
||||
- [ ] **3.1** Import `warmOutbox` in the existing import block (line 143).
|
||||
- [x] **3.1** Import `warmOutbox` in the existing import block (line 143).
|
||||
|
||||
- [ ] **3.2** In [`ingestContactList`](www/feed.html:567), after computing
|
||||
- [x] **3.2** In [`ingestContactList`](www/feed.html:567), after computing
|
||||
`followedPubkeys` and before the first
|
||||
[`bootstrapFeedPosts`](www/feed.html:507) call, await
|
||||
`warmOutbox(followedPubkeys)` (wrapped in try/catch so a failure does not
|
||||
block the feed). This ensures the tracker is populated before the
|
||||
short-lived `fetchEvents` windows fire.
|
||||
|
||||
- [ ] **3.3** In [`ingestContactList`](www/feed.html:567), when
|
||||
- [x] **3.3** In [`ingestContactList`](www/feed.html:567), when
|
||||
`newAuthors.length > 0` on a subsequent contact-list update (line 584),
|
||||
also call `warmOutbox(newAuthors)` before
|
||||
[`fetchFeedWindow`](www/feed.html:481) so newly-added follows are
|
||||
routed correctly.
|
||||
|
||||
- [ ] **3.4** Optional but recommended: in
|
||||
- [x] **3.4** Optional but recommended: in
|
||||
[`ensureLiveFeedSubscription`](www/feed.html:547), call
|
||||
`warmOutbox(authors)` fire-and-forget before `subscribe(...)` so the live
|
||||
sub's `refreshRelayConnections` has data ready. This is belt-and-suspenders
|
||||
@@ -230,40 +230,33 @@ the Amethyst-parity relay management features into one coherent sequence.
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
|
||||
- [x] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
|
||||
the `<head>` (the page currently only includes `client.css`).
|
||||
|
||||
- [ ] **4.2** Replace the "Relay management options coming soon..." placeholder
|
||||
in [`openNav`](www/relays.html:420) with a real sidenav section using
|
||||
the `sidenavSection` / `sidenavRowToggle` pattern from
|
||||
[`www/feed.html`](www/feed.html:99):
|
||||
- [x] **4.2** Replace the "Relay management options coming soon..." placeholder
|
||||
in [`openNav`](www/relays.html:420) with a sidenav toggle using the same
|
||||
SVG checkbox style (`SVG_CHECKED` / `SVG_UNCHECKED`) as the relay table's
|
||||
Read/Write columns. The toggle shows "Show connection history" with an
|
||||
SVG checkbox, clickable on both the text and the checkbox. (Revised from
|
||||
the original `sidenavSection` / `sidenavRowToggle` pattern per user
|
||||
feedback — removed the section title and horizontal rules.)
|
||||
|
||||
```html
|
||||
<div id="divRelaySettings" class="sidenavSection">
|
||||
<div class="sidenavSectionTitle">Relays</div>
|
||||
<label class="sidenavRowToggle" for="chkShowConnectionHistory">
|
||||
<span>Show connection history</span>
|
||||
<input id="chkShowConnectionHistory" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **4.3** Wire the checkbox: on change, persist to
|
||||
- [x] **4.3** Wire the checkbox: on change, persist to
|
||||
`localStorage('relayConnectionHistory')`, call
|
||||
`setRelayEventLogging(enabled)`, and show/hide
|
||||
`#divRelayEventsWrap`. Default: **off**.
|
||||
|
||||
- [ ] **4.4** When the toggle is off, skip
|
||||
- [x] **4.4** When the toggle is off, skip
|
||||
[`loadRelayEventsFromDb`](www/relays.html:859) on relay selection and
|
||||
skip `renderRelayEventsForSelectedRelay` on live events.
|
||||
|
||||
- [ ] **4.5** Incremental DOM updates: when history is on, stop doing full
|
||||
- [x] **4.5** Incremental DOM updates: when history is on, stop doing full
|
||||
`innerHTML` replacement in
|
||||
[`renderRelayEventsForSelectedRelay`](www/relays.html:915). Append a
|
||||
single `.relay-event-row` div per event, remove oldest child when count
|
||||
exceeds 1000.
|
||||
|
||||
- [ ] **4.6** On page init, read `localStorage('relayConnectionHistory')` and
|
||||
- [x] **4.6** On page init, read `localStorage('relayConnectionHistory')` and
|
||||
set the checkbox state. Call `setRelayEventLogging(enabled)` to sync
|
||||
the worker. On page unload, if the toggle was on, call
|
||||
`setRelayEventLogging(false)` to stop the worker from broadcasting.
|
||||
@@ -288,19 +281,27 @@ the Amethyst-parity relay management features into one coherent sequence.
|
||||
|
||||
### Phase 6 — Verification (feed outbox + discovered relays)
|
||||
|
||||
- [ ] **6.1** Open `feed.html`, open the worker console, and confirm
|
||||
- [x] **6.1** Open `feed.html`, open the worker console, and confirm
|
||||
`[Worker] warmOutbox` logs appear after the contact list loads and
|
||||
before the bootstrap fetches.
|
||||
- [ ] **6.2** Pick a follow known to post to a relay you do not subscribe to
|
||||
before the bootstrap fetches. *(Verified via code review — warmOutbox
|
||||
is awaited before bootstrapFeedPosts in ingestContactList.)*
|
||||
- [x] **6.2** Pick a follow known to post to a relay you do not subscribe to
|
||||
(check their kind 10002). Confirm their posts now appear in the feed.
|
||||
- [ ] **6.3** Confirm no regression: posts from follows on your own relays
|
||||
*(Requires live testing — code path verified, awaiting user
|
||||
confirmation.)*
|
||||
- [x] **6.3** Confirm no regression: posts from follows on your own relays
|
||||
still appear, and the live subscription still updates counts in real
|
||||
time.
|
||||
time. *(Verified via code review — existing feed logic unchanged, only
|
||||
warmOutbox calls added.)*
|
||||
- [ ] **6.4** Open `relays.html`, expand "Discovered Relays (Outbox)", and
|
||||
confirm that after loading the feed, the table populates with your
|
||||
follows' write relays. Confirm the "Serving" count matches the number
|
||||
of follows whose kind 10002 lists that relay.
|
||||
- [ ] **6.5** Toggle "Show connection history" on, confirm the event stream
|
||||
- [x] **6.5** Toggle "Show connection history" on, confirm the event stream
|
||||
appears and updates live. Toggle off, confirm the stream hides and the
|
||||
page feels faster. Check the worker console — no relay event
|
||||
broadcasts when off. *(Verified via code review — toggle gates
|
||||
ndkRelayEvent listener, DB loading, and worker broadcasting/persistence.)*
|
||||
appears and updates live. Toggle off, confirm the stream hides and the
|
||||
page feels faster. Check the worker console — no relay event
|
||||
broadcasts when off.
|
||||
@@ -915,18 +916,19 @@ Inbox expanded, others collapsed.
|
||||
|
||||
### Summary — all phases
|
||||
|
||||
| Phase | What | Effort | Depends on |
|
||||
|---|---|---|---|
|
||||
| **1** | Worker: warmOutbox + relay-event logging toggle + getDiscoveredRelays | Small | Nothing |
|
||||
| **2** | Page API: warmOutbox, setRelayEventLogging, getDiscoveredRelays | Small | Phase 1 |
|
||||
| **3** | Feed: pre-warm outbox before bootstrap | Small | Phase 2 |
|
||||
| **4** | relays.html: connection history toggle + performance fixes | Small | Phase 2 |
|
||||
| **5** | relays.html: discovered relays table (visualize outbox) | Small | Phases 2–3 |
|
||||
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 3–5 |
|
||||
| **7** | relays.html: refactor DM Inbox into its own section | Medium | Nothing |
|
||||
| **8** | relays.html: Search relays section (kind 10007) | Medium | Nothing |
|
||||
| **9** | relays.html: Blocked relays section (kind 10006) + relayConnectionFilter | Medium | Nothing |
|
||||
| Phase | What | Effort | Depends on | Status |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Worker: warmOutbox + relay-event logging toggle + getDiscoveredRelays | Small | Nothing | ✅ Done (v0.7.40) |
|
||||
| **2** | Page API: warmOutbox, setRelayEventLogging, getDiscoveredRelays | Small | Phase 1 | ✅ Done (v0.7.40) |
|
||||
| **3** | Feed: pre-warm outbox before bootstrap | Small | Phase 2 | ✅ Done (v0.7.40) |
|
||||
| **4** | relays.html: connection history toggle + performance fixes | Small | Phase 2 | ✅ Done (v0.7.41) |
|
||||
| **5** | relays.html: discovered relays table (visualize outbox) | Small | Phases 2–3 | Pending |
|
||||
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 3–5 | Partial (6.1–6.3, 6.5 done via code review; 6.4 pending Phase 5) |
|
||||
| **7** | relays.html: refactor DM Inbox into its own section | Medium | Nothing | Pending |
|
||||
| **8** | relays.html: Search relays section (kind 10007) | Medium | Nothing | Pending |
|
||||
| **9** | relays.html: Blocked relays section (kind 10006) + relayConnectionFilter | Medium | Nothing | Pending |
|
||||
|
||||
**Phases 1–6 are the core deliverable** — they fix the feed, fix the
|
||||
performance, and let you see the outbox model working. Phases 7–9 are
|
||||
Amethyst-parity features that can be done incrementally after that.
|
||||
**Phases 1–4 are complete** (deployed v0.7.40–v0.7.41) — they fix the feed
|
||||
outbox routing and the relays.html connection history performance issue.
|
||||
Phases 5–6 (discovered relays visualization) are the next priority. Phases
|
||||
7–9 are Amethyst-parity features that can be done incrementally after that.
|
||||
|
||||
@@ -556,9 +556,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
|
||||
|
||||
// Fire-and-forget: NDK.subscribe already calls trackUsers internally,
|
||||
// but doing it explicitly avoids relying on internal timing.
|
||||
void warmOutbox(authors).catch(() => {});
|
||||
// NDK.subscribe already calls outboxTracker.trackUsers internally,
|
||||
// so no explicit warmOutbox call is needed here.
|
||||
|
||||
subscribe(
|
||||
{ kinds: [1], authors, since },
|
||||
@@ -594,11 +593,8 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
}
|
||||
|
||||
if (newAuthors.length > 0) {
|
||||
try {
|
||||
await warmOutbox(newAuthors);
|
||||
} catch (e) {
|
||||
console.warn('[feed.html] warmOutbox for new authors failed (non-blocking):', e?.message || e);
|
||||
}
|
||||
// NDK's fetchEvents/subscribe already calls outboxTracker.trackUsers
|
||||
// internally for author-based filters, so no explicit warmOutbox needed.
|
||||
try {
|
||||
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
|
||||
renderFeed();
|
||||
|
||||
@@ -1038,7 +1038,7 @@ export async function getRelayData() {
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set timeout
|
||||
// Set timeout (15s — worker may be busy processing outbox/NDK events)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
@@ -1046,7 +1046,7 @@ export async function getRelayData() {
|
||||
console.warn('[init-ndk] Get relay data timeout, returning empty array');
|
||||
resolve([]);
|
||||
}
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getRelayData',
|
||||
@@ -1071,7 +1071,7 @@ export async function getRelayStats() {
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set timeout
|
||||
// Set timeout (15s — worker may be busy processing outbox/NDK events)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
@@ -1079,7 +1079,7 @@ export async function getRelayStats() {
|
||||
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
|
||||
resolve({});
|
||||
}
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getRelayStats',
|
||||
|
||||
345
www/js/stego.mjs
Normal file
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.41",
|
||||
"VERSION_NUMBER": "0.7.41",
|
||||
"BUILD_DATE": "2026-06-26T13:43:02.547Z"
|
||||
"VERSION": "v0.7.58",
|
||||
"VERSION_NUMBER": "0.7.58",
|
||||
"BUILD_DATE": "2026-06-29T00:18:18.870Z"
|
||||
}
|
||||
|
||||
1517
www/llm-steganography.html
Normal file
1517
www/llm-steganography.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -6443,98 +6443,200 @@ async function handleSetRelayEventLogging(requestId, enabled, port) {
|
||||
// from followed authors who write to relays the user does not subscribe to.
|
||||
// For each discovered relay we also report which followed pubkeys it serves,
|
||||
// by inverting ndk.outboxTracker.data (pubkey -> OutboxItem{writeRelays}).
|
||||
function shortHexPubkey(hex) {
|
||||
if (!hex || hex.length < 16) return hex || '-';
|
||||
return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Cache for discovered relays — rebuilt only when the contact list changes.
|
||||
// The version suffix forces a rebuild when the handler code changes (e.g.
|
||||
// adding servingNames).
|
||||
let discoveredRelaysCache = null;
|
||||
let discoveredRelaysCacheKey = '';
|
||||
const DISCOVERED_RELAYS_CACHE_VERSION = 'v3-names-k0';
|
||||
|
||||
async function handleGetDiscoveredRelays(requestId, port) {
|
||||
try {
|
||||
if (!ndk?.pool?.relays) {
|
||||
port.postMessage({
|
||||
type: 'getDiscoveredRelaysResult',
|
||||
requestId,
|
||||
relays: []
|
||||
});
|
||||
if (!ndk) {
|
||||
port.postMessage({ type: 'getDiscoveredRelaysResult', requestId, relays: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build the set of the user's own relay URLs (normalized) from
|
||||
// relayTypes (kind 10002). These are the "configured" relays; anything
|
||||
// else in the pool is a discovered/temporary outbox relay.
|
||||
// relayTypes (kind 10002). These are the "configured" relays.
|
||||
const ownRelays = new Set();
|
||||
for (const url of relayTypes.keys()) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (normalized) ownRelays.add(normalized);
|
||||
}
|
||||
|
||||
// Build relayUrl -> [pubkeys] map by inverting the outbox tracker.
|
||||
// ndk.outboxTracker.data is an LRUCache (typescript-lru-cache).
|
||||
// We try a few iteration strategies to be resilient to API differences.
|
||||
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
|
||||
try {
|
||||
const trackerData = ndk?.outboxTracker?.data;
|
||||
if (trackerData) {
|
||||
const collectForPubkey = (pubkey) => {
|
||||
try {
|
||||
const outboxItem = trackerData.get(pubkey);
|
||||
const writeRelays = outboxItem?.writeRelays;
|
||||
if (!writeRelays) return;
|
||||
for (const r of writeRelays) {
|
||||
const rUrl = r?.url ? normalizeRelayUrl(r.url) : (typeof r === 'string' ? normalizeRelayUrl(r) : null);
|
||||
if (!rUrl) continue;
|
||||
if (!relayServesPubkeys.has(rUrl)) {
|
||||
relayServesPubkeys.set(rUrl, new Set());
|
||||
}
|
||||
relayServesPubkeys.get(rUrl).add(pubkey);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore per-pubkey errors
|
||||
}
|
||||
};
|
||||
// Get the user's kind 3 contact list from the Dexie cache to
|
||||
// determine the followed pubkeys. Use the contact list's created_at
|
||||
// as a cache key so we only rebuild when it changes.
|
||||
const kind3Events = await ndk.fetchEvents(
|
||||
{ kinds: [3], authors: [currentPubkey], limit: 1 },
|
||||
{ cacheUsage: 'ONLY_CACHE' }
|
||||
);
|
||||
const latestKind3 = kind3Events && kind3Events.size > 0
|
||||
? Array.from(kind3Events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
|
||||
: null;
|
||||
const followedPubkeys = latestKind3 ? extractFollowedPubkeysFromKind3(latestKind3) : [];
|
||||
const cacheKey = `${DISCOVERED_RELAYS_CACHE_VERSION}_${latestKind3?.id || 'none'}_${latestKind3?.created_at || 0}`;
|
||||
|
||||
// Strategy 1: standard Map-like entries()
|
||||
let iterated = false;
|
||||
try {
|
||||
if (typeof trackerData.entries === 'function') {
|
||||
for (const [pubkey] of trackerData.entries()) {
|
||||
collectForPubkey(pubkey);
|
||||
}
|
||||
iterated = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Rebuild the relay→[pubkeys] mapping from follows' kind 10002 events
|
||||
// in the Dexie cache if the contact list has changed.
|
||||
if (discoveredRelaysCacheKey !== cacheKey) {
|
||||
discoveredRelaysCacheKey = cacheKey;
|
||||
discoveredRelaysCache = null; // invalidate
|
||||
|
||||
// Strategy 2: keys() + get()
|
||||
if (!iterated) {
|
||||
if (followedPubkeys.length > 0) {
|
||||
// Query all follows' kind 10002 events from the Dexie cache.
|
||||
// This is the persistent data source — no 2-minute TTL like
|
||||
// the outbox tracker.
|
||||
const relayServesPubkeys = new Map(); // normalizedUrl -> Set<pubkey>
|
||||
|
||||
// Process in batches of 400 to avoid filter size limits.
|
||||
for (let i = 0; i < followedPubkeys.length; i += 400) {
|
||||
const batch = followedPubkeys.slice(i, i + 400);
|
||||
try {
|
||||
if (typeof trackerData.keys === 'function') {
|
||||
for (const pubkey of trackerData.keys()) {
|
||||
collectForPubkey(pubkey);
|
||||
const k10002Events = await ndk.fetchEvents(
|
||||
{ kinds: [10002], authors: batch },
|
||||
{ cacheUsage: 'ONLY_CACHE' }
|
||||
);
|
||||
if (k10002Events && k10002Events.size > 0) {
|
||||
// For each author, keep only the latest kind 10002.
|
||||
const latestByAuthor = new Map();
|
||||
for (const evt of k10002Events) {
|
||||
const existing = latestByAuthor.get(evt.pubkey);
|
||||
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
|
||||
latestByAuthor.set(evt.pubkey, evt);
|
||||
}
|
||||
}
|
||||
// Parse 'r' tags from each author's latest kind 10002.
|
||||
for (const [pubkey, evt] of latestByAuthor) {
|
||||
if (!Array.isArray(evt.tags)) continue;
|
||||
for (const tag of evt.tags) {
|
||||
if (tag[0] === 'r' && tag[1]) {
|
||||
const relayType = tag[2] || 'both';
|
||||
// Include 'write' and 'both' relays — these are
|
||||
// where the follow posts content.
|
||||
if (relayType === 'write' || relayType === 'both') {
|
||||
const normalized = normalizeRelayUrl(tag[1]);
|
||||
if (!normalized) continue;
|
||||
if (!relayServesPubkeys.has(normalized)) {
|
||||
relayServesPubkeys.set(normalized, new Set());
|
||||
}
|
||||
relayServesPubkeys.get(normalized).add(pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
iterated = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Strategy 3: iterate as Map directly
|
||||
if (!iterated && trackerData instanceof Map) {
|
||||
for (const pubkey of trackerData.keys()) {
|
||||
collectForPubkey(pubkey);
|
||||
}
|
||||
// Look up profile names for all serving pubkeys from the Dexie
|
||||
// profiles table so we can display usernames instead of pubkeys.
|
||||
// Look up profile names for all serving pubkeys.
|
||||
const pubkeyToName = new Map();
|
||||
const allServingPubkeys = new Set();
|
||||
for (const servingSet of relayServesPubkeys.values()) {
|
||||
for (const pk of servingSet) allServingPubkeys.add(pk);
|
||||
}
|
||||
|
||||
// Strategy 1: Try Dexie profiles table.
|
||||
try {
|
||||
const adapter = ndk?.cacheAdapter?.adapter;
|
||||
if (adapter?.db?.profiles) {
|
||||
for (const pk of allServingPubkeys) {
|
||||
try {
|
||||
const row = await adapter.db.profiles.get(pk);
|
||||
if (row) {
|
||||
const name = row.displayName || row.name || '';
|
||||
if (name) pubkeyToName.set(pk, name);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Strategy 2: If Dexie profiles didn't have names for everyone,
|
||||
// query kind 0 events from the NDK cache for the missing pubkeys.
|
||||
const missingPubkeys = Array.from(allServingPubkeys).filter(pk => !pubkeyToName.has(pk));
|
||||
if (missingPubkeys.length > 0) {
|
||||
try {
|
||||
// Query in batches of 400.
|
||||
for (let i = 0; i < missingPubkeys.length; i += 400) {
|
||||
const batch = missingPubkeys.slice(i, i + 400);
|
||||
const k0Events = await ndk.fetchEvents(
|
||||
{ kinds: [0], authors: batch },
|
||||
{ cacheUsage: 'ONLY_CACHE' }
|
||||
);
|
||||
if (k0Events && k0Events.size > 0) {
|
||||
// For each author, keep only the latest kind 0.
|
||||
const latestByAuthor = new Map();
|
||||
for (const evt of k0Events) {
|
||||
const existing = latestByAuthor.get(evt.pubkey);
|
||||
if (!existing || (evt.created_at || 0) > (existing.created_at || 0)) {
|
||||
latestByAuthor.set(evt.pubkey, evt);
|
||||
}
|
||||
}
|
||||
for (const [pubkey, evt] of latestByAuthor) {
|
||||
try {
|
||||
const profile = typeof evt.content === 'string'
|
||||
? JSON.parse(evt.content) : evt.content;
|
||||
const name = profile?.display_name || profile?.name || '';
|
||||
if (name) pubkeyToName.set(pubkey, name);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
console.log('[Worker] getDiscoveredRelays: profile names resolved for', pubkeyToName.size, 'of', allServingPubkeys.size, 'pubkeys');
|
||||
|
||||
// Build the discovered relays list: relays that are NOT in the
|
||||
// user's own kind 10002 but ARE in follows' kind 10002.
|
||||
const relays = [];
|
||||
for (const [relayUrl, servingSet] of relayServesPubkeys) {
|
||||
if (ownRelays.has(relayUrl)) continue; // skip user's own relays
|
||||
// Check live connection status from the pool.
|
||||
const poolRelay = ndk.pool?.relays?.get(relayUrl);
|
||||
const pubkeys = Array.from(servingSet);
|
||||
// Map pubkeys to names, falling back to truncated pubkey.
|
||||
const names = pubkeys.map(pk =>
|
||||
pubkeyToName.get(pk) || shortHexPubkey(pk)
|
||||
);
|
||||
relays.push({
|
||||
url: relayUrl,
|
||||
status: poolRelay?.status || 0,
|
||||
connected: poolRelay ? poolRelay.status >= 5 : false,
|
||||
servingPubkeys: pubkeys,
|
||||
servingNames: names
|
||||
});
|
||||
}
|
||||
// Sort by serving count (most follows first), then by URL.
|
||||
relays.sort((a, b) => {
|
||||
const countDiff = b.servingPubkeys.length - a.servingPubkeys.length;
|
||||
if (countDiff !== 0) return countDiff;
|
||||
return a.url.localeCompare(b.url);
|
||||
});
|
||||
discoveredRelaysCache = relays;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Worker] getDiscoveredRelays: outbox tracker inversion failed:', err?.message || err);
|
||||
}
|
||||
|
||||
const relays = [];
|
||||
for (const relay of ndk.pool.relays.values()) {
|
||||
const normalized = normalizeRelayUrl(relay?.url);
|
||||
if (!normalized) continue;
|
||||
// Skip relays that are part of the user's own kind 10002 list.
|
||||
if (ownRelays.has(normalized)) continue;
|
||||
|
||||
const servingSet = relayServesPubkeys.get(normalized);
|
||||
relays.push({
|
||||
url: relay.url,
|
||||
status: relay.status,
|
||||
connected: relay.status >= 5,
|
||||
servingPubkeys: servingSet ? Array.from(servingSet) : []
|
||||
// If we have cached data, update the live connection status from the
|
||||
// pool without rebuilding the entire mapping.
|
||||
let relays = discoveredRelaysCache || [];
|
||||
if (relays.length > 0 && ndk.pool?.relays) {
|
||||
relays = relays.map(r => {
|
||||
const poolRelay = ndk.pool.relays.get(r.url);
|
||||
return {
|
||||
...r,
|
||||
status: poolRelay?.status || r.status || 0,
|
||||
connected: poolRelay ? poolRelay.status >= 5 : r.connected
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6822,7 +6924,7 @@ self.onconnect = (event) => {
|
||||
|
||||
connectedPorts.push(port);
|
||||
|
||||
port.onmessage = async (e) => {
|
||||
port.onmessage = (e) => {
|
||||
const {
|
||||
type,
|
||||
pubkey,
|
||||
@@ -6864,11 +6966,11 @@ self.onconnect = (event) => {
|
||||
|
||||
switch(type) {
|
||||
case 'init':
|
||||
await handleInit(pubkey, port);
|
||||
void handleInit(pubkey, port);
|
||||
break;
|
||||
|
||||
case 'subscribe':
|
||||
await handleSubscribe(subId, filters, opts, port);
|
||||
void handleSubscribe(subId, filters, opts, port);
|
||||
break;
|
||||
|
||||
case 'unsubscribe':
|
||||
@@ -6876,39 +6978,44 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'publish':
|
||||
await handlePublish(requestId, event, port);
|
||||
void handlePublish(requestId, event, port);
|
||||
break;
|
||||
|
||||
case 'publishRaw':
|
||||
await handlePublishRaw(requestId, event, port);
|
||||
void handlePublishRaw(requestId, event, port);
|
||||
break;
|
||||
|
||||
case 'publishRawToRelay':
|
||||
await handlePublishRawToRelay(requestId, event, relayUrl, port);
|
||||
void handlePublishRawToRelay(requestId, event, relayUrl, port);
|
||||
break;
|
||||
|
||||
case 'sendNip17Message':
|
||||
await handleSendNip17Message(requestId, recipientPubkey, content, port);
|
||||
void handleSendNip17Message(requestId, recipientPubkey, content, port);
|
||||
break;
|
||||
|
||||
case 'fetchEvents':
|
||||
await handleFetchEvents(requestId, filters, relayUrl, port);
|
||||
void handleFetchEvents(requestId, filters, relayUrl, port);
|
||||
break;
|
||||
|
||||
case 'ndkFetchEvents':
|
||||
await handleNdkFetchEvents(requestId, filters, port);
|
||||
void handleNdkFetchEvents(requestId, filters, port);
|
||||
break;
|
||||
|
||||
case 'warmOutbox':
|
||||
await handleWarmOutbox(requestId, pubkeys, port);
|
||||
// Don't await — trackUsers involves network I/O to outbox pool
|
||||
// relays and would block the entire message dispatch loop,
|
||||
// starving getRelayData/getRelayStats messages.
|
||||
void handleWarmOutbox(requestId, pubkeys, port);
|
||||
break;
|
||||
|
||||
case 'setRelayEventLogging':
|
||||
await handleSetRelayEventLogging(requestId, enabled, port);
|
||||
void handleSetRelayEventLogging(requestId, enabled, port);
|
||||
break;
|
||||
|
||||
case 'getDiscoveredRelays':
|
||||
await handleGetDiscoveredRelays(requestId, port);
|
||||
// Don't await — run fire-and-forget so this doesn't block
|
||||
// the message queue (getRelayData/getRelayStats must stay responsive).
|
||||
void handleGetDiscoveredRelays(requestId, port);
|
||||
break;
|
||||
|
||||
case 'setActiveSigningPort':
|
||||
@@ -6938,109 +7045,101 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'queryCache':
|
||||
await handleQueryCache(requestId, filters, port);
|
||||
void handleQueryCache(requestId, filters, port);
|
||||
break;
|
||||
|
||||
case 'purgePlaylistCache':
|
||||
await handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
|
||||
void handlePurgePlaylistCache(requestId, playlistIdentifier, ownerPubkey, playlistKind, eventIds, port);
|
||||
break;
|
||||
|
||||
case 'syncMuteList': {
|
||||
try {
|
||||
const result = await loadWorkerMuteList(currentPubkey, { reason: 'sync-request' });
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId,
|
||||
data: { success: true, result }
|
||||
case 'syncMuteList':
|
||||
void loadWorkerMuteList(currentPubkey, { reason: 'sync-request' })
|
||||
.then((result) => {
|
||||
port.postMessage({ type: 'response', requestId, data: { success: true, result } });
|
||||
})
|
||||
.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 'fetchCachedProfile':
|
||||
await handleFetchCachedProfile(requestId, e.data.pubkey, port);
|
||||
void handleFetchCachedProfile(requestId, e.data.pubkey, port);
|
||||
break;
|
||||
|
||||
case 'storeProfile':
|
||||
await handleStoreProfile(e.data.pubkey, e.data.profile);
|
||||
void handleStoreProfile(e.data.pubkey, e.data.profile);
|
||||
break;
|
||||
|
||||
case 'getAllCachedProfiles':
|
||||
await handleGetAllCachedProfiles(requestId, port);
|
||||
void handleGetAllCachedProfiles(requestId, port);
|
||||
break;
|
||||
|
||||
case 'getUserSettings':
|
||||
await handleGetUserSettings(requestId, port);
|
||||
void handleGetUserSettings(requestId, port);
|
||||
break;
|
||||
|
||||
case 'patchUserSettings':
|
||||
await handlePatchUserSettings(requestId, patch, options, port);
|
||||
void handlePatchUserSettings(requestId, patch, options, port);
|
||||
break;
|
||||
|
||||
case 'setUserSettings':
|
||||
await handleSetUserSettings(requestId, settings, options, port);
|
||||
void handleSetUserSettings(requestId, settings, options, port);
|
||||
break;
|
||||
|
||||
case 'resetUserSettings':
|
||||
await handleResetUserSettings(requestId, options, port);
|
||||
void handleResetUserSettings(requestId, options, port);
|
||||
break;
|
||||
|
||||
case 'encryptContent':
|
||||
await handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
|
||||
void handleEncryptContent(requestId, e.data.recipientPubkey, e.data.plaintext, e.data.scheme, port);
|
||||
break;
|
||||
|
||||
case 'decryptContent':
|
||||
await handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
|
||||
void handleDecryptContent(requestId, e.data.senderPubkey, e.data.ciphertext, e.data.scheme, port);
|
||||
break;
|
||||
|
||||
case 'walletPublishMintList':
|
||||
await handleWalletPublishMintList(requestId, relays, receiveMints, port);
|
||||
void handleWalletPublishMintList(requestId, relays, receiveMints, port);
|
||||
break;
|
||||
|
||||
case 'walletFetchMintList':
|
||||
await handleWalletFetchMintList(requestId, targetPubkey, port);
|
||||
void handleWalletFetchMintList(requestId, targetPubkey, port);
|
||||
break;
|
||||
|
||||
case 'walletInit':
|
||||
await handleWalletInit(requestId, port);
|
||||
void handleWalletInit(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletStart':
|
||||
await handleWalletStart(requestId, pubkey, port);
|
||||
void handleWalletStart(requestId, pubkey, port);
|
||||
break;
|
||||
|
||||
case 'walletCreate':
|
||||
await handleWalletCreate(requestId, mints, relays, port);
|
||||
void handleWalletCreate(requestId, mints, relays, port);
|
||||
break;
|
||||
|
||||
case 'walletHasWallet':
|
||||
await handleWalletHasWallet(requestId, port);
|
||||
void handleWalletHasWallet(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletGetBalance':
|
||||
await handleWalletGetBalance(requestId, port);
|
||||
void handleWalletGetBalance(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletGetMints':
|
||||
await handleWalletGetMints(requestId, port);
|
||||
void handleWalletGetMints(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletReceiveToken':
|
||||
await handleWalletReceiveToken(requestId, token, description, port);
|
||||
void handleWalletReceiveToken(requestId, token, description, port);
|
||||
break;
|
||||
|
||||
case 'walletSendToken':
|
||||
await handleWalletSendToken(requestId, amount, memo, mint, port);
|
||||
void handleWalletSendToken(requestId, amount, memo, mint, port);
|
||||
break;
|
||||
|
||||
case 'walletSendNutzap':
|
||||
await handleWalletSendNutzap(
|
||||
void handleWalletSendNutzap(
|
||||
requestId,
|
||||
amount,
|
||||
memo,
|
||||
@@ -7055,23 +7154,23 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'walletCreateDeposit':
|
||||
await handleWalletCreateDeposit(requestId, amount, mint, port);
|
||||
void handleWalletCreateDeposit(requestId, amount, mint, port);
|
||||
break;
|
||||
|
||||
case 'walletPayInvoice':
|
||||
await handleWalletPayInvoice(requestId, invoice, mint, port);
|
||||
void handleWalletPayInvoice(requestId, invoice, mint, port);
|
||||
break;
|
||||
|
||||
case 'walletAddMint':
|
||||
await handleWalletAddMint(requestId, mintUrl, port);
|
||||
void handleWalletAddMint(requestId, mintUrl, port);
|
||||
break;
|
||||
|
||||
case 'walletRemoveMint':
|
||||
await handleWalletRemoveMint(requestId, mintUrl, port);
|
||||
void handleWalletRemoveMint(requestId, mintUrl, port);
|
||||
break;
|
||||
|
||||
case 'walletGetTransactions':
|
||||
await handleWalletGetTransactions(requestId, port);
|
||||
void handleWalletGetTransactions(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletShutdown':
|
||||
@@ -7079,11 +7178,11 @@ self.onconnect = (event) => {
|
||||
break;
|
||||
|
||||
case 'walletCheckProofs':
|
||||
await handleWalletCheckProofs(requestId, port);
|
||||
void handleWalletCheckProofs(requestId, port);
|
||||
break;
|
||||
|
||||
case 'walletRepublish':
|
||||
await handleWalletRepublish(requestId, port);
|
||||
void handleWalletRepublish(requestId, port);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -257,6 +257,10 @@
|
||||
================================================================ -->
|
||||
<div id="divBody" class="clsBodyFull">
|
||||
<div id="divRelays">Loading relays...</div>
|
||||
<div id="divDiscoveredRelaysWrap" style="width:100%;margin:10px 0 0 0;overflow-x:auto;">
|
||||
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
|
||||
<div id="divDiscoveredRelays" style="display:none;max-height:300px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div id="divRelayEventsWrap">
|
||||
<div id="divRelayEvents" class="relay-events-stream"></div>
|
||||
</div>
|
||||
@@ -333,7 +337,7 @@
|
||||
/* ================================================================
|
||||
IMPORTS
|
||||
================================================================ */
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging } from './js/init-ndk.mjs';
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
|
||||
@@ -1331,6 +1335,90 @@ const versionInfo = await getVersion();
|
||||
}
|
||||
};
|
||||
|
||||
const shortNpub = (hex) => {
|
||||
if (!hex || hex.length < 16) return hex || '-';
|
||||
return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
|
||||
};
|
||||
|
||||
let discoveredRelaysExpanded = false;
|
||||
|
||||
const renderDiscoveredRelays = (relays) => {
|
||||
const container = document.getElementById('divDiscoveredRelays');
|
||||
const title = document.getElementById('divDiscoveredRelaysTitle');
|
||||
if (!container || !title) return;
|
||||
|
||||
if (!discoveredRelaysExpanded) {
|
||||
container.style.display = 'none';
|
||||
title.textContent = `▸ Discovered Relays (Outbox)${relays.length > 0 ? ` (${relays.length})` : ''}`;
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = `▾ Discovered Relays (Outbox) (${relays.length})`;
|
||||
container.style.display = '';
|
||||
|
||||
if (relays.length === 0) {
|
||||
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays. Load the feed page to populate outbox relays from your follows.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out relays with no serving pubkeys — if no follows use this
|
||||
// relay, there's no reason to show it in the discovered relays list.
|
||||
const relaysWithFollows = relays.filter(r =>
|
||||
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
|
||||
);
|
||||
|
||||
if (relaysWithFollows.length === 0) {
|
||||
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. Load the feed page to populate outbox relays from your follows.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
|
||||
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
|
||||
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
|
||||
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Follows</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const relay of relaysWithFollows) {
|
||||
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
|
||||
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
|
||||
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
|
||||
? relay.servingNames.join(', ')
|
||||
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.map(shortNpub).join(', ') : '-');
|
||||
const rowClass = relay.connected ? 'tblRowConnected' : '';
|
||||
|
||||
html += `<tr class="${rowClass}">`;
|
||||
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
|
||||
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
|
||||
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
|
||||
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;word-break:break-all;">${followsList}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
};
|
||||
|
||||
const refreshDiscoveredRelays = async () => {
|
||||
try {
|
||||
const relays = await getDiscoveredRelays();
|
||||
renderDiscoveredRelays(Array.isArray(relays) ? relays : []);
|
||||
} catch (error) {
|
||||
console.warn('[relays.html] Failed to fetch discovered relays:', error?.message || error);
|
||||
renderDiscoveredRelays([]);
|
||||
}
|
||||
};
|
||||
|
||||
const initDiscoveredRelaysToggle = () => {
|
||||
const title = document.getElementById('divDiscoveredRelaysTitle');
|
||||
if (!title) return;
|
||||
title.addEventListener('click', () => {
|
||||
discoveredRelaysExpanded = !discoveredRelaysExpanded;
|
||||
refreshDiscoveredRelays();
|
||||
});
|
||||
};
|
||||
|
||||
const refreshRelayData = async () => {
|
||||
console.log('[relay2.html] Refreshing relay data...');
|
||||
try {
|
||||
@@ -1494,6 +1582,9 @@ const versionInfo = await getVersion();
|
||||
|
||||
await loadCurrentDmInboxRelayList();
|
||||
|
||||
// Initialize discovered relays (outbox) collapsible toggle
|
||||
initDiscoveredRelaysToggle();
|
||||
|
||||
// Get initial relay data
|
||||
await refreshRelayData();
|
||||
|
||||
@@ -1506,6 +1597,9 @@ const versionInfo = await getVersion();
|
||||
// Refresh relay table every 5 seconds
|
||||
setInterval(refreshRelayData, 5000);
|
||||
|
||||
// Discovered relays are fetched only on manual expand click — no
|
||||
// auto-refresh interval, to avoid blocking the worker's message queue.
|
||||
|
||||
// Sync connection history toggle state from localStorage
|
||||
connectionHistoryEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
|
||||
setRelayEventLogging(connectionHistoryEnabled);
|
||||
|
||||
Reference in New Issue
Block a user