From 3752e5d4cd96acb75724dd0db316835ced72ebc8 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Fri, 7 Aug 2026 06:54:50 -0400 Subject: [PATCH] Add FIPS directory page and relay disable/enable feature with outbox model toggle --- plans/fips-directory-page.md | 150 ++++++ plans/relay-disable-enable.md | 185 +++++++ www/fips-directory.html | 958 ++++++++++++++++++++++++++++++++++ www/js/init-ndk.mjs | 56 ++ www/js/version.json | 6 +- www/ndk-worker.js | 205 +++++++- www/relays.html | 76 ++- 7 files changed, 1627 insertions(+), 9 deletions(-) create mode 100644 plans/fips-directory-page.md create mode 100644 plans/relay-disable-enable.md create mode 100644 www/fips-directory.html diff --git a/plans/fips-directory-page.md b/plans/fips-directory-page.md new file mode 100644 index 0000000..b31f314 --- /dev/null +++ b/plans/fips-directory-page.md @@ -0,0 +1,150 @@ +# FIPS Directory Page Plan + +## Goal +Turn [`www/fips-directory.html`](../www/fips-directory.html:1) (currently a copy of the template) into a community FIPS link directory where: + +- Anyone can **view** FIPS links (no login required). +- Logged-in users can **add / remove** their own links. +- There is **no admin** — every person maintains their own **blocklist** (NIP-51 mute list) and decides what to hide from their own view. +- The site owner publishes their curated FIPS links the same way everyone else does (no hardcoded seed data). + +## Standards Used + +### Links — [NIP-B0: Web Bookmarking](../nips/B0.md:1) (`kind:39701`) +Each FIPS link is a **separate replaceable event**, one per URL: + +- `kind`: `39701` +- `d` tag: the FIPS URL (scheme prefix omitted for `https://`, per NIP-B0; for `http://` and `ws://` FIPS URLs the full URL is used) +- `title` tag: the link name +- `t` tag: `fips-directory` (so the page can subscribe to just FIPS-directory bookmarks) +- `content`: markdown description of the link (can be empty) +- `published_at` tag: unix seconds string + +**Add a link** = publish a new `kind:39701` event with `d = `. +**Remove a link** = publish a [NIP-09](../nips/09.md:1) deletion request (`kind:5`) referencing the event id, OR publish an empty/blank replacement (NIP-B0 is replaceable by `d`, so republishing with empty content effectively removes it). We'll use NIP-09 deletion for a clean removal. + +### Blocklist — [NIP-51: Lists](../nips/51.md:1) (`kind:10000` mute list) +The standard Nostr mute list. Each user publishes one replaceable event: + +- `kind`: `10000` +- `p` tags: pubkeys the user wants hidden from their own view +- `content`: optionally NIP-44-encrypted private items (we only use public `p` tags) + +**Block a publisher** = re-publish `kind:10000` with their `p` tag added. +**Unblock** = re-publish without that `p` tag. + +> **Sovereign moderation:** blocking only affects the blocking user's own view. It does not delete or hide content for anyone else. This is exactly how every NIP-51-compatible Nostr client already works. + +## Page Behavior + +### Public (not logged in) +- Subscribe to all `kind:39701` events with `#t = ['fips-directory']`. +- Render every publisher's links as cards grouped by publisher. +- Show a "Sign in to add your links" prompt. +- No blocklist controls (no identity to attach a blocklist to). + +### Logged in +- Load the user's own `kind:10000` mute list and filter blocked pubkeys out of the rendered list. +- Show an **Add Link** form (name, URL, description). +- Show **remove** buttons on the user's own links (publishes a NIP-09 deletion). +- Show a **block / unblock** button on each publisher's section (for publishers other than yourself). +- Publishing uses [`publishEvent()`](../www/js/init-ndk.mjs:653) from `init-ndk.mjs` (auto-signs via the worker). + +### Auth mode +- `authMode = 'optional'` (matches [`www/app-stacks.html`](../www/app-stacks.html:235)) — public load, login on demand via the sidenav logout/login button or the "Sign in" prompt. + +## UI Layout (inside `#divBody`) + +``` +┌─────────────────────────────────────────────┐ +│ FIPS DIRECTORY │ (header text) +├─────────────────────────────────────────────┤ +│ [Sign in to add your links] (if anon) │ +│ │ +│ ┌─ Add Link form ─────────────────────────┐ │ (only if logged in) +│ │ Name / URL / Description │ │ +│ │ [Add Link] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ ┌─ Publisher: laantungir ─────────────────┐ │ +│ │ [block] (if logged in & not you) │ │ +│ │ • My Relay — ws://....fips/relay/ │ │ +│ │ [remove] (if yours) │ │ +│ │ • My Client — http://....fips/client │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ ┌─ Publisher: someone-else ───────────────┐ │ +│ │ [block] [unblock] │ │ +│ │ • Their Thing — http://....fips/thing │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +- Cards use the existing `client.css` variables (`--border-color`, `--border-radius`, `--color`, `--muted-color`, `--font-family`, etc.) — same inline-style pattern as [`www/app-stacks.html`](../www/app-stacks.html:564). +- Links open in a new tab (`target="_blank" rel="noopener"`). +- FIPS URLs are clickable as-is (the server is FIPS-enabled, per the user). + +## Data Structures (in-page state) + +```js +// Map: eventId -> { id, pubkey, url, title, description, createdAt } +let bookmarks = new Map(); + +// Set of blocked pubkeys (from current user's kind 10000) +let blocklist = new Set(); + +// The current user's kind 10000 event id (for re-publishing) +let myMuteListEventId = null; + +// Load flags +let bookmarksLoaded = false; +let blocklistLoaded = false; +``` + +## Implementation Steps + +1. **Set page title & header text** — change `TEMPLATE` to `FIPS DIRECTORY` and set `.divHeaderText` to "FIPS DIRECTORY". +2. **Set `authMode = 'optional'`** as the default (like app-stacks.html). +3. **Add state variables** — `bookmarks` (Map), `blocklist` (Set), `myMuteListEventId`, load flags. +4. **Add `esc()` helper** — prevent XSS from relay content (same as app-stacks.html). +5. **Add subscription + listener for `kind:39701` with `#t=['fips-directory']`** — `subscribeFipsBookmarks()` + `initBookmarkListener()`. Parse each event into the bookmarks Map, keyed by event id. Re-render on each event / EOSE. +6. **Add subscription + listener for `kind:10000` (mute list)** — `subscribeMuteLists()` + `initMuteListListener()`. Only the current user's mute list matters for filtering; subscribe broadly, pick out the logged-in user's. Store blocked pubkeys in the `blocklist` Set. +7. **Add `renderDirectory()`** — builds the directory HTML (add-link form if logged in, publisher sections grouped by pubkey, link cards with block/remove buttons), writes to `#divBody`. Filters out blocked pubkeys. +8. **Add `doAddLink()`** — reads the form, publishes a `kind:39701` event with `d=`, `title=`, `t=fips-directory`, `content=`. +9. **Add `doRemoveLink()`** — publishes a `kind:5` (NIP-09 deletion) event referencing the bookmark's event id, then removes it from the local Map and re-renders. +10. **Add `doToggleBlock()`** — adds/removes a `p` tag in the user's `kind:10000` mute list, re-publishes, updates the `blocklist` Set, re-renders. +11. **Wire up subscriptions in `main()`** — after `initializeAuthenticatedPageFeatures()`, call the subscribe/listener init functions (bookmarks subscription runs for both public and logged-in; mute list subscription only matters when logged in but can run always). +12. **Footer UX note** — "Public mode / Sign in from side menu to add your links" when anonymous. + +## Architecture Diagram + +```mermaid +flowchart LR + A[User loads fips-directory.html] --> B[authMode = optional] + B --> C{Logged in?} + C -- no --> D[Show links + Sign in prompt] + C -- yes --> E[Show links + Add/Remove form + Block controls] + D --> F[Subscribe kind 39701 t:fips-directory] + E --> F + E --> G[Subscribe kind 10000 mute list] + F --> H[Render directory cards grouped by pubkey] + G --> H + H --> I[User clicks link -> opens FIPS URL] + E --> J[Add -> publishEvent kind 39701] + E --> K[Remove -> publishEvent kind 5 NIP-09 deletion] + E --> L[Block/Unblock -> publishEvent kind 10000] + J --> H + K --> H + L --> H +``` + +## NIP References +- [NIP-B0: Web Bookmarking](../nips/B0.md:1) — `kind:39701` for links +- [NIP-51: Lists](../nips/51.md:1) — `kind:10000` mute list for blocklist +- [NIP-09: Event Deletion Request](../nips/09.md:1) — `kind:5` for removing a link + +## Notes +- No new JS modules needed — everything is inline in the HTML, matching the app-stacks.html pattern. +- No CSS file changes — uses existing `client.css` variables via inline styles. +- The `subscribe()` / `publishEvent()` / `getPubkey()` APIs from [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:1) are already imported by the template. +- Fully standards-based: other Nostr clients that support NIP-B0 bookmarks and NIP-51 mute lists will interoperate with the data we publish. diff --git a/plans/relay-disable-enable.md b/plans/relay-disable-enable.md new file mode 100644 index 0000000..67b778c --- /dev/null +++ b/plans/relay-disable-enable.md @@ -0,0 +1,185 @@ +# Relay Disable/Enable Feature Plan + +## Goal +Allow users to **temporarily disable and enable individual relays** in the app without modifying the contents of `kind 10002` (the user's persisted relay list). This is an app-wide, session-level state that affects all pages via the shared NDK worker. + +## Current State + +### What exists today +- **`relays.html`** — clicking the "connected" status icon calls [`handleRelayReconnect(relayUrl)`](../www/relays.html:1440) which calls [`reconnectRelay(relayUrl)`](../www/js/init-ndk.mjs:1696) → sends `reconnectRelay` message to worker +- **Worker [`handleReconnectRelay()`](../www/ndk-worker.js:96970)** — disconnects the relay, waits 500ms, then reconnects it (a toggle/restart behavior) +- **Worker [`handleDisconnect()`](../www/ndk-worker.js:96802)** — disconnects ALL relays (used for logout only) +- **NDK auto-reconnect** — when a relay disconnects, NDK's connectivity layer automatically attempts reconnection with exponential backoff (see `handleReconnection()` at [line 19884](../www/ndk-worker.js:19884) and flapping detection at [line 19844](../www/ndk-worker.js:19844)) + +### What's missing +There is **no concept of "temporarily disabled"** — the only options are: +1. Reconnect (disconnect + immediate reconnect) — relay comes right back up +2. Full disconnect (logout) — disconnects everything + +If you just call `relay.disconnect()`, NDK's auto-reconnect logic will bring it back online within seconds. + +### The outbox model +The worker's NDK instance has a full outbox model ([`ndk.outboxTracker`](../www/ndk-worker.js:54365), [`ndk.outboxPool`](../www/ndk-worker.js:54759)) that: +- Resolves followed authors' `kind 10002` relay lists +- Adds **temporary relays** to the pool via `pool.useTemporaryRelay()` to fetch events from those authors +- These temporary relays auto-remove after inactivity (`temporaryRelayTimers`) + +NDK has a built-in **`relayConnectionFilter`** callback that is checked in three key places: +1. **Pool's `addRelay()`** ([line 21879](../www/ndk-worker.js:21879)) — refuses to add relays that fail the filter (including temporary outbox relays) +2. **Outbox tracker relay list resolution** ([line 54404](../www/ndk-worker.js:54404)) — filters disabled relays out of `readRelays` and `writeRelays` sets +3. **All three NDK pool implementations** check the filter ([line 47128](../www/ndk-worker.js:47128), [line 73531](../www/ndk-worker.js:73531), [line 78145](../www/ndk-worker.js:78145)) + +The `relayConnectionFilter` is already used elsewhere in the codebase (the `ndk-store` module at [line 83105](../www/ndk-worker.js:83105) uses it for `blockedRelays`), but the **worker's main NDK instance** (created at [line 90998](../www/ndk-worker.js:90998)) does **not** currently set it. + +**This is the ideal mechanism for the disable feature** — setting `ndk.relayConnectionFilter` on the worker's main NDK instance will automatically prevent disabled relays from being used by the outbox model, without any additional outbox-specific code. + +## Design + +### Worker-side: `disabledRelays` Set +Add a `Set` in the worker that tracks temporarily disabled relay URLs (normalized, trailing-slash form). + +### `ndk.relayConnectionFilter` — the key mechanism +Set `ndk.relayConnectionFilter` on the worker's main NDK instance to check `disabledRelays`: +```js +ndk.relayConnectionFilter = (relayUrl) => { + return !disabledRelays.has(normalizeRelayUrl(relayUrl)); +}; +``` +This automatically covers the outbox model: +- ✅ Prevents disabled relays from being added as temporary outbox relays +- ✅ Filters disabled relays out of outbox tracker's read/write relay sets +- ✅ Prevents NDK from connecting to disabled relays via any path + +### New worker message handlers + +#### `disableRelay` — disable a relay +1. Add the relay URL (normalized) to `disabledRelays` +2. Update `ndk.relayConnectionFilter` (or it reads `disabledRelays` live) +3. Get the relay from the pool, disconnect it +4. **Suppress auto-reconnect**: the NDK connectivity layer's `handleReconnection()` is called on disconnect. We need to intercept this. + +#### `enableRelay` — re-enable a relay +1. Remove the relay URL from `disabledRelays` +2. Get the relay from the pool, call `relay.connect()` +3. If the relay isn't in the pool (e.g. write-only), add it on demand (same as `handleReconnectRelay` does) + +#### `getDisabledRelays` — query disabled state +Returns the current `disabledRelays` set to the page, so the UI can show disabled state. + +### Suppressing auto-reconnect +The NDK pool's `disconnectHandler` (see [line 21908](../www/ndk-worker.js:21908)) fires on relay disconnect and triggers reconnection. We need to prevent this for disabled relays. + +**Approach**: In the worker's existing relay disconnect event handling, check if the relay URL is in `disabledRelays` before allowing reconnection. The disconnect handler in the pool calls `relay.connect()` on disconnect — we intercept by checking `disabledRelays` before calling connect. + +The most surgical approach: in `attachRelayEventListeners` (where disconnect handlers are registered), add a check: if the relay URL is in `disabledRelays`, don't trigger reconnection. Since the worker wraps NDK, we can intercept at the worker level. + +### `handleGetRelayData` enhancement +Add a `disabled` boolean field to each relay entry in the response, so pages can show disabled state without a separate query. + +### `init-ndk.mjs` API +Add three new exported functions: +- `disableRelay(relayUrl)` — sends `disableRelay` message to worker +- `enableRelay(relayUrl)` — sends `enableRelay` message to worker +- `getDisabledRelays()` — sends `getDisabledRelays` message, returns Promise + +### `relays.html` UI changes +- **Add a new "Enabled" column** between "Relay" and "Connected" in the relay table +- Uses the same checkbox style as the Read/Write/DM Inbox columns (`SVG_CHECKED`/`SVG_UNCHECKED`) +- Clicking the Enabled checkbox: + - If **enabled** (checked) → **disable** the relay (call `disableRelay()`, unchecks box, disconnects relay) + - If **disabled** (unchecked) → **enable** the relay (call `enableRelay()`, checks box, reconnects relay) +- The "Connected" column keeps its existing behavior (click to reconnect a disconnected relay) +- The `disabled` field from `getRelayData` drives the checkbox state +- The add-relay row gets a `-` placeholder in the Enabled column (new relays are enabled by default) + +Current table columns: +``` +| (remove) | Relay | Connected | Read | Write | DM Inbox | Reads | Writes | Connection Time | +``` + +New table columns: +``` +| (remove) | Relay | Enabled | Connected | Read | Write | DM Inbox | Reads | Writes | Connection Time | +``` + +### Persistence +**No persistence** — disabled relays are session-only. On page reload / worker restart, all relays reconnect normally from `kind 10002`. This matches the "temporarily" requirement. + +## Architecture Diagram + +```mermaid +flowchart TD + A[User clicks relay status icon] --> B{Relay state?} + B -- connected --> C[disableRelay - disconnect and suppress reconnect] + B -- disabled --> D[enableRelay - reconnect] + B -- disconnected, not disabled --> E[reconnectRelay - existing toggle] + + C --> F[Worker: add to disabledRelays Set] + F --> G[Worker: relay.disconnect] + G --> H[NDK disconnect handler fires] + H --> I{Is relay in disabledRelays?} + I -- yes --> J[Skip auto-reconnect] + I -- no --> K[Auto-reconnect with backoff] + + D --> L[Worker: remove from disabledRelays Set] + L --> M[Worker: relay.connect] + M --> N[Relay reconnects] + + E --> O[Worker: disconnect + 500ms + reconnect] +``` + +## Outbox Model Toggle + +Add a checkbox in the sidenav (alongside "Show connection history") to enable/disable the outbox model. Default: enabled. + +### How it works +NDK has `autoConnectUserRelays` (default `true`) which controls whether NDK auto-connects to relays discovered via the outbox tracker. Toggling this at runtime effectively enables/disables the outbox model: +- **Disable**: set `ndk.autoConnectUserRelays = false`, disconnect all temporary/discovered relays +- **Enable**: set `ndk.autoConnectUserRelays = true`, let NDK re-discover and connect as needed + +The `outboxTracker` still resolves relay lists when disabled, but NDK won't connect to those relays — so no outbox fetches happen. + +### Persistence +`localStorage` key `outboxModel` (`'true'`/`'false'`), default `'true'`. Read on page load, sent to worker on init. + +### UI +Same pattern as "Show connection history" checkbox — a clickable row in the sidenav with `SVG_CHECKED`/`SVG_UNCHECKED` icon. + +## Implementation Steps + +### Worker ([`www/ndk-worker.js`](../www/ndk-worker.js:1)) +1. **Add `disabledRelays` Set** — near the top of the worker logic, alongside other relay state +2. **Set `ndk.relayConnectionFilter`** — after NDK init, set it to check `disabledRelays` (covers outbox model for disabled relays) +3. **Add `handleDisableRelay(relayUrl, port)`** — add to set, disconnect relay, suppress reconnect +4. **Add `handleEnableRelay(relayUrl, port)`** — remove from set, reconnect relay +5. **Add `handleGetDisabledRelays(requestId, port)`** — return the set +6. **Modify auto-reconnect suppression** — in the disconnect event handler or `attachRelayEventListeners`, check `disabledRelays` before allowing reconnection +7. **Modify `handleGetRelayData`** — add `disabled: boolean` field to each relay entry +8. **Add `handleSetOutboxModel(enabled, port)`** — set `ndk.autoConnectUserRelays`, disconnect temporary relays if disabling +9. **Add message handler cases** — `disableRelay`, `enableRelay`, `getDisabledRelays`, `setOutboxModel` in the switch statement + +### Init module ([`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs:1)) +10. **Add `disableRelay(relayUrl)`** export — sends `disableRelay` message +11. **Add `enableRelay(relayUrl)`** export — sends `enableRelay` message +12. **Add `getDisabledRelays()`** export — sends `getDisabledRelays`, returns Promise +13. **Add `setOutboxModel(enabled)`** export — sends `setOutboxModel` message + +### Relays page ([`www/relays.html`](../www/relays.html:1)) +14. **Import new functions** — add `disableRelay`, `enableRelay`, `setOutboxModel` to the import from init-ndk.mjs +15. **Add "Enabled" column to table header** — new `` between "Relay" and "Connected" +16. **Add "Enabled" checkbox to each relay row** — `SVG_CHECKED`/`SVG_UNCHECKED` based on `relay.disabled` field, with `data-enable-relay-url` attribute for click handler +17. **Add "Enabled" placeholder to add-relay row** — `-` (new relays are enabled by default) +18. **Add click handler for Enabled checkbox** — calls `disableRelay()` or `enableRelay()` based on current state, then refreshes relay data +19. **Keep existing "Connected" click handler** — still calls `handleRelayReconnect()` for reconnecting disconnected (but enabled) relays +20. **Add outbox model checkbox to sidenav** — alongside "Show connection history", with localStorage persistence and `setOutboxModel()` call +21. **Sync outbox model state on init** — read from localStorage, send to worker on page load + +## Key Considerations + +- **No `kind 10002` changes** — disabled relays stay in the user's relay list, they're just temporarily disconnected +- **Session-only** — disabled state is lost on worker restart / page reload +- **App-wide** — the worker is a SharedWorker, so disabling a relay on `relays.html` affects all open pages +- **Auto-reconnect suppression** — the critical piece; NDK will try to reconnect unless we explicitly prevent it +- **Write-only relays** — need to handle the case where a disabled relay isn't in the main pool (same as `handleReconnectRelay` does) +- **Outbox model coverage** — `ndk.relayConnectionFilter` automatically prevents disabled relays from being used by the outbox tracker, so disabled relays won't be added as temporary relays for fetching events from followed authors +- **`relayConnectionFilter` is already proven** — the `ndk-store` module already uses this exact pattern for `blockedRelays` at [line 83105](../www/ndk-worker.js:83105) diff --git a/www/fips-directory.html b/www/fips-directory.html new file mode 100644 index 0000000..93431d0 --- /dev/null +++ b/www/fips-directory.html @@ -0,0 +1,958 @@ + + + + + + + FIPS DIRECTORY + + + + + + + + + + + + + +
+ +
+ + +
+
+ +
+ +
+
FIPS DIRECTORY
+
+ +
+ +
+
+ + +
+ + +
+ + +
+
+
+
+
0 sats
+
+ + +
+
+ +
+ +
+
+
+ +
+
AI
+
+
No saved providers yet.
+
+
+ + +
+
+ リレー +
+
+ Loading relays... +
+
+ +
+ +
ブロッサム
+ +
Loading blossom servers...
+ +
+ + +
+ v0.0.1 +
+ + +
+
+
+ + + + + + + + + diff --git a/www/js/init-ndk.mjs b/www/js/init-ndk.mjs index fc7cb81..f76de8e 100644 --- a/www/js/init-ndk.mjs +++ b/www/js/init-ndk.mjs @@ -1706,6 +1706,62 @@ export function reconnectRelay(relayUrl) { console.log('[init-ndk] Requesting reconnect for relay:', relayUrl); } +/** + * Temporarily disable a relay — disconnects it and suppresses auto-reconnect + * without modifying kind 10002. The relayConnectionFilter also prevents the + * outbox model from re-adding it. Session-only; lost on worker restart. + */ +export function disableRelay(relayUrl) { + if (!ndkWorker) { + throw new Error('NDK worker not initialized. Call initNDKPage() first.'); + } + ndkWorker.port.postMessage({ type: 'disableRelay', relayUrl }); + console.log('[init-ndk] Disabling relay:', relayUrl); +} + +/** + * Re-enable a previously disabled relay — reconnects it. + */ +export function enableRelay(relayUrl) { + if (!ndkWorker) { + throw new Error('NDK worker not initialized. Call initNDKPage() first.'); + } + ndkWorker.port.postMessage({ type: 'enableRelay', relayUrl }); + console.log('[init-ndk] Enabling relay:', relayUrl); +} + +/** + * Get the set of currently disabled relay URLs. + * @returns {Promise} Array of disabled relay URLs + */ +export async function getDisabledRelays() { + if (!ndkWorker) { + throw new Error('NDK worker not initialized. Call initNDKPage() first.'); + } + return new Promise((resolve, reject) => { + const requestId = `getDisabledRelays_${Date.now()}_${++requestCounter}`; + const timeout = setTimeout(() => { + pendingRequests.delete(requestId); + resolve([]); // Resolve with empty array on timeout + }, 5000); + pendingRequests.set(requestId, { resolve: (data) => { clearTimeout(timeout); resolve(data?.disabledRelays || []); }, reject }); + ndkWorker.port.postMessage({ type: 'getDisabledRelays', requestId }); + }); +} + +/** + * Enable or disable the NDK outbox model at runtime. + * When disabled, NDK stops auto-connecting to discovered outbox relays. + * @param {boolean} enabled + */ +export function setOutboxModel(enabled) { + if (!ndkWorker) { + throw new Error('NDK worker not initialized. Call initNDKPage() first.'); + } + ndkWorker.port.postMessage({ type: 'setOutboxModel', enabled }); + console.log('[init-ndk] Outbox model:', enabled ? 'enabled' : 'disabled'); +} + /** * Helper to disconnect worker */ diff --git a/www/js/version.json b/www/js/version.json index cf6555f..280970e 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.99", - "VERSION_NUMBER": "0.7.99", - "BUILD_DATE": "2026-08-04T20:24:55.694Z" + "VERSION": "v0.7.100", + "VERSION_NUMBER": "0.7.100", + "BUILD_DATE": "2026-08-07T10:54:50.095Z" } diff --git a/www/ndk-worker.js b/www/ndk-worker.js index 2f8ca31..99bc770 100644 --- a/www/ndk-worker.js +++ b/www/ndk-worker.js @@ -89719,6 +89719,15 @@ const RELAY_KEEPALIVE_INTERVAL_MS = 30000; const SUBSCRIPTION_DEDUP_MAX_IDS = 5000; const SUBSCRIPTION_DEDUP_PRUNE_COUNT = 1500; +// Temporarily disabled relays (session-only, not persisted to kind 10002). +// Keyed by normalized relay URL (trailing-slash form from normalizeRelayUrl()). +let disabledRelays = new Set(); + +// Outbox model toggle (session-level, synced from page localStorage on init). +// When false, ndk.autoConnectUserRelays is set to false so NDK stops +// auto-connecting to discovered outbox relays. +let outboxModelEnabled = true; + // App-wide user settings state const USER_SETTINGS_DB_NAME = 'ndk-shared-settings'; const USER_SETTINGS_DB_VERSION = 2; @@ -90652,6 +90661,22 @@ function attachRelayEventListeners(relay) { status: relay.status, ...(lastClose || {}) }, 'relay'); + + // Suppress auto-reconnect for temporarily disabled relays. + // NDK's connectivity layer will try to reconnect with backoff after a + // disconnect. If this relay is in the disabledRelays set, forcibly + // cancel the reconnect timer and set status to DISCONNECTED (1) so + // NDK leaves it alone until the user re-enables it. + if (normalized && disabledRelays.has(normalized)) { + console.log(`[Worker] 🔇 Relay ${relay.url} is disabled — suppressing auto-reconnect`); + if (relay.connectivity) { + if (relay.connectivity.reconnectTimeout) { + clearTimeout(relay.connectivity.reconnectTimeout); + relay.connectivity.reconnectTimeout = null; + } + relay.connectivity._status = 1; // DISCONNECTED + } + } }); relay.on('notice', (notice) => { @@ -91004,7 +91029,24 @@ async function initNDK() { } else { console.warn('[Worker] NDKRelayAuthPolicies not available, relay auth will not work'); } - + + // Set relayConnectionFilter to block temporarily disabled relays from being + // (re)added to the pool — including by the outbox model's temporary relays. + // This reads disabledRelays live so enable/disable takes effect immediately. + ndk.relayConnectionFilter = (relayUrl) => { + const normalized = normalizeRelayUrl(relayUrl); + if (normalized && disabledRelays.has(normalized)) { + return false; + } + // Also check the raw URL in case normalization differs + return !disabledRelays.has(relayUrl); + }; + console.log('[Worker] relayConnectionFilter set for disabled relays'); + + // Apply outbox model toggle (default: enabled) + ndk.autoConnectUserRelays = outboxModelEnabled; + console.log('[Worker] Outbox model:', outboxModelEnabled ? 'enabled' : 'disabled'); + // Set pubkey on signer if (currentPubkey) { messageSigner.pubkey = currentPubkey; @@ -96934,13 +96976,20 @@ function handleGetRelayData(requestId, port) { } } + // Check if this relay is temporarily disabled + const relayUrlForDisabledCheck = relay?.url || normalizedUrl || url; + const normalizedForDisabled = normalizeRelayUrl(relayUrlForDisabledCheck); + const isDisabled = (normalizedForDisabled && disabledRelays.has(normalizedForDisabled)) || + disabledRelays.has(relayUrlForDisabledCheck); + return { url: relay?.url || normalizedUrl || url, status: relay?.status ?? 0, // 0 = DISCONNECTED connectionTime: connectedAt, type: type || 'both', lastError, - fromRelayList + fromRelayList, + disabled: isDisabled }; }); @@ -96964,6 +97013,140 @@ function handleGetRelayStats(requestId, port) { }); } +/** + * Handle disable relay request — temporarily disconnect a relay and suppress + * auto-reconnect without modifying kind 10002. The relayConnectionFilter + * also prevents the outbox model from re-adding it. + */ +function handleDisableRelay(relayUrl, port) { + console.log('[Worker] Disabling relay:', relayUrl); + if (!ndk || !ndk.pool) { + port.postMessage({ type: 'response', data: { success: false, error: 'NDK not initialized' } }); + return; + } + + const normalized = normalizeRelayUrl(relayUrl); + if (!normalized) { + port.postMessage({ type: 'response', data: { success: false, error: 'Invalid relay URL' } }); + return; + } + + disabledRelays.add(normalized); + // Also add the raw form in case normalization differs in some code paths + disabledRelays.add(relayUrl); + + // Disconnect the relay if it's in the pool + let relay = getRelayFromPool(relayUrl) || getRelayFromPool(normalized); + if (relay) { + console.log('[Worker] Disconnecting disabled relay:', relay.url); + // Cancel any pending reconnect timer + if (relay.connectivity?.reconnectTimeout) { + clearTimeout(relay.connectivity.reconnectTimeout); + relay.connectivity.reconnectTimeout = null; + } + relay.disconnect(); + } else { + // Write-only relays may not be in the main pool — that's fine, + // the relayConnectionFilter will prevent them from being added. + console.log('[Worker] Relay not in pool (may be write-only):', relayUrl); + } + + port.postMessage({ type: 'response', data: { success: true, disabledRelays: Array.from(disabledRelays) } }); +} + +/** + * Handle enable relay request — re-enable a previously disabled relay. + */ +function handleEnableRelay(relayUrl, port) { + console.log('[Worker] Enabling relay:', relayUrl); + if (!ndk || !ndk.pool) { + port.postMessage({ type: 'response', data: { success: false, error: 'NDK not initialized' } }); + return; + } + + const normalized = normalizeRelayUrl(relayUrl); + if (normalized) disabledRelays.delete(normalized); + disabledRelays.delete(relayUrl); + + // Reconnect the relay + let relay = getRelayFromPool(relayUrl) || getRelayFromPool(normalized); + if (relay) { + console.log('[Worker] Reconnecting enabled relay:', relay.url); + if (relay.connectivity) { + relay.connectivity.resetReconnectionState(); + } + relay.connect().catch((e) => { + console.error('[Worker] Failed to reconnect enabled relay:', relayUrl, e); + }); + } else { + // Write-only relay not in pool — add it on demand (same as handleReconnectRelay) + const relayType = getRelayType(relayUrl) || getRelayType(normalized); + const isKnownWriteOnly = relayType === 'write'; + if (isKnownWriteOnly && typeof ndk.addExplicitRelay === 'function') { + const slashForm = normalized && normalized.endsWith('/') + ? normalized + : (normalized || relayUrl) + '/'; + try { + const added = ndk.addExplicitRelay(slashForm); + const addedRelay = added || getRelayFromPool(slashForm) || getRelayFromPool(normalized); + if (addedRelay) { + attachRelayEventListeners(addedRelay); + addedRelay.connect().catch((e) => { + console.error('[Worker] Failed to connect write-only relay on enable:', relayUrl, e); + }); + } + } catch (e) { + console.error('[Worker] Failed to add write-only relay on enable:', relayUrl, e); + } + } + } + + port.postMessage({ type: 'response', data: { success: true, disabledRelays: Array.from(disabledRelays) } }); +} + +/** + * Handle get disabled relays request — returns the current set of disabled relay URLs. + */ +function handleGetDisabledRelays(requestId, port) { + port.postMessage({ + type: 'response', + requestId, + data: { disabledRelays: Array.from(disabledRelays) } + }); +} + +/** + * Handle set outbox model request — enable or disable the NDK outbox model at runtime. + * When disabled, NDK stops auto-connecting to discovered outbox relays. + */ +function handleSetOutboxModel(enabled, port) { + console.log('[Worker] Setting outbox model:', enabled); + outboxModelEnabled = enabled; + + if (!ndk) { + port.postMessage({ type: 'response', data: { success: false, error: 'NDK not initialized' } }); + return; + } + + ndk.autoConnectUserRelays = enabled; + + if (!enabled) { + // Disconnect all temporary/discovered relays (those with temporaryRelayTimers) + if (ndk.pool && ndk.pool.temporaryRelayTimers) { + const tempRelayUrls = Array.from(ndk.pool.temporaryRelayTimers.keys()); + console.log('[Worker] Disconnecting', tempRelayUrls.length, 'temporary outbox relays'); + for (const tempUrl of tempRelayUrls) { + const tempRelay = getRelayFromPool(tempUrl); + if (tempRelay) { + tempRelay.disconnect(); + } + } + } + } + + port.postMessage({ type: 'response', data: { success: true, outboxModelEnabled: outboxModelEnabled } }); +} + /** * Handle reconnect relay request */ @@ -97329,7 +97512,23 @@ self.onconnect = (event) => { case 'reconnectRelay': handleReconnectRelay(e.data.relayUrl, port); break; - + + case 'disableRelay': + handleDisableRelay(e.data.relayUrl, port); + break; + + case 'enableRelay': + handleEnableRelay(e.data.relayUrl, port); + break; + + case 'getDisabledRelays': + handleGetDisabledRelays(requestId, port); + break; + + case 'setOutboxModel': + handleSetOutboxModel(e.data.enabled, port); + break; + case 'disconnect': handleDisconnect(); break; diff --git a/www/relays.html b/www/relays.html index b19d776..eca8afb 100644 --- a/www/relays.html +++ b/www/relays.html @@ -337,7 +337,7 @@ /* ================================================================ IMPORTS ================================================================ */ - import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } from './js/init-ndk.mjs'; + import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, disableRelay, enableRelay, setOutboxModel, 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'; @@ -376,6 +376,7 @@ const versionInfo = await getVersion(); let reconnectingRelays = new Set(); // Track which relays are reconnecting let currentRelayList = []; // Store current relay list with types let pendingRemovalRelayUrl = null; // Two-click confirmation for relay deletion + let locallyDisabledRelays = new Set(); // Track disabled relays on page side (for immediate UI feedback) let addRelayCanRead = true; let addRelayCanWrite = true; let addRelayDraftValue = ''; // Preserve typed add-relay input across table refreshes @@ -436,11 +437,16 @@ const versionInfo = await getVersion(); } const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true'; connectionHistoryEnabled = historyEnabled; + const outboxEnabled = localStorage.getItem('outboxModel') !== 'false'; // default: true divSideNavBody.innerHTML = `
Show connection history
${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}
+
+ Outbox model +
${outboxEnabled ? SVG_CHECKED : SVG_UNCHECKED}
+
Broadcast Relays @@ -501,6 +507,24 @@ const versionInfo = await getVersion(); document.getElementById('divRelaySettings').addEventListener('click', toggleHistory); } + // Wire up the outbox model toggle + const divOutboxToggle = document.getElementById('divOutboxToggleCheckbox'); + if (divOutboxToggle) { + const toggleOutbox = () => { + const checked = localStorage.getItem('outboxModel') !== 'false'; // current state + const newState = !checked; + localStorage.setItem('outboxModel', newState); + setOutboxModel(newState); + divOutboxToggle.innerHTML = newState ? SVG_CHECKED : SVG_UNCHECKED; + console.log('[relays.html] Outbox model toggled:', newState); + }; + divOutboxToggle.addEventListener('click', (e) => { + e.stopPropagation(); + toggleOutbox(); + }); + document.getElementById('divOutboxSettings').addEventListener('click', toggleOutbox); + } + // Wire up the Broadcast Relays collapsible section. initBroadcastRelaysSection(); @@ -827,6 +851,7 @@ const versionInfo = await getVersion(); html += ''; html += ''; html += 'Relay'; + html += 'Enabled'; html += 'Connected'; html += 'Read'; html += 'Write'; @@ -875,9 +900,16 @@ const versionInfo = await getVersion(); const dmInboxCheckbox = isDmInboxRelayEnabled(relay.url) ? SVG_CHECKED : SVG_UNCHECKED; + // Enabled checkbox (disabled relays show unchecked) + // Check both the worker's disabled field and our local tracking set + const isDisabled = relay.disabled || locallyDisabledRelays.has(relay.url); + const isEnabled = !isDisabled; + const enabledCheckbox = isEnabled ? SVG_CHECKED : SVG_UNCHECKED; + html += ``; html += `${removeIcon}`; html += `${relay.url}`; + html += `${enabledCheckbox}`; html += `${statusIcon}`; html += `${readCheckbox}`; html += `${writeCheckbox}`; @@ -895,6 +927,7 @@ const versionInfo = await getVersion(); html += '-'; html += ''; html += '-'; + html += '-'; html += `${addReadCheckbox}`; html += `${addWriteCheckbox}`; html += `${addDmInboxCheckbox}`; @@ -922,7 +955,15 @@ const versionInfo = await getVersion(); handleRelayReconnect(relayUrl); }); }); - + + // Add click handlers to enable/disable toggle checkboxes + document.querySelectorAll('.divSvg[data-enable-relay-url]').forEach(cell => { + cell.addEventListener('click', () => { + const relayUrl = cell.getAttribute('data-enable-relay-url'); + handleRelayEnableToggle(relayUrl); + }); + }); + // Add click handlers to read/write toggle checkboxes document.querySelectorAll('.divSvg[data-relay-index]').forEach(cell => { cell.addEventListener('click', () => { @@ -1455,7 +1496,31 @@ const versionInfo = await getVersion(); refreshRelayData(); }, 5000); }; - + + /* ================================================================ + RELAY ENABLE/DISABLE TOGGLE + ================================================================ */ + const handleRelayEnableToggle = (relayUrl) => { + // Check both the worker's disabled field and our local tracking set + const relay = currentRelayList.find(r => r.url === relayUrl); + const isDisabled = relay?.disabled || locallyDisabledRelays.has(relayUrl); + + console.log('[relays.html] Toggle enable/disable for relay:', relayUrl, 'currently disabled:', isDisabled); + + if (isDisabled) { + // Currently disabled → enable it + locallyDisabledRelays.delete(relayUrl); + enableRelay(relayUrl); + } else { + // Currently enabled → disable it + locallyDisabledRelays.add(relayUrl); + disableRelay(relayUrl); + } + + // Refresh table immediately for instant UI feedback + refreshRelayData(); + }; + /* ================================================================ RELAY EVENT LOGGING ================================================================ */ @@ -1812,6 +1877,11 @@ const versionInfo = await getVersion(); } } + // Sync outbox model toggle state from localStorage and send to worker + const outboxModelEnabled = localStorage.getItem('outboxModel') !== 'false'; // default: true + setOutboxModel(outboxModelEnabled); + console.log('[relays.html] Outbox model on init:', outboxModelEnabled); + // Stop the worker from broadcasting relay events when the page is closing window.addEventListener('beforeunload', () => { if (connectionHistoryEnabled) {