Files
client/plans/relay-disable-enable.md

186 lines
12 KiB
Markdown

# 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<string>` 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<Set>
### `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<Set>
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 `<th>` 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)