Add FIPS directory page and relay disable/enable feature with outbox model toggle
This commit is contained in:
@@ -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 = <url>`.
|
||||||
|
**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 `<title>TEMPLATE</title>` 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=<url>`, `title=<name>`, `t=fips-directory`, `content=<description>`.
|
||||||
|
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.
|
||||||
@@ -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<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)
|
||||||
@@ -0,0 +1,958 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<html lang="en" dir="ltr">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>FIPS DIRECTORY</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="./css/client.css" />
|
||||||
|
|
||||||
|
<!-- Initialize theme BEFORE any components load -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark-mode');
|
||||||
|
if (document.body) {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
||||||
|
|
||||||
|
<!-- SVG.js library (required by HamburgerMorphing) -->
|
||||||
|
<script src="./js/vendor/svg.min.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<!-- ================================================================
|
||||||
|
HAMBURGER BUTTON (Fixed, separate from header)
|
||||||
|
================================================================
|
||||||
|
The hamburger button is a fixed element outside the header
|
||||||
|
to ensure it stays visible above the sidenav (z-index: 10 > 3).
|
||||||
|
================================================================ -->
|
||||||
|
<div id="divSvgHam" class="divHeaderButtons">
|
||||||
|
<!-- HamburgerMorphing will be injected here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
HEADER
|
||||||
|
================================================================
|
||||||
|
Standard header with title (center).
|
||||||
|
================================================================ -->
|
||||||
|
<div id="divHeader">
|
||||||
|
<div id="divHeaderFlexLeft">
|
||||||
|
<!-- Hamburger is now separate fixed element -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divHeaderFlexCenter">
|
||||||
|
<div class="divHeaderText">FIPS DIRECTORY</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divHeaderFlexRight">
|
||||||
|
<!-- No button in header right - logout is in sidenav footer -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
BODY
|
||||||
|
================================================================
|
||||||
|
Main content area. Add your page-specific content here.
|
||||||
|
================================================================ -->
|
||||||
|
<div id="divBody">
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
FOOTER
|
||||||
|
================================================================
|
||||||
|
Three-section footer layout:
|
||||||
|
- Left: Relay status animations (HamburgerMorphing instances)
|
||||||
|
- Center: General status information
|
||||||
|
- Right: Additional information
|
||||||
|
================================================================ -->
|
||||||
|
<div id="divFooter">
|
||||||
|
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||||
|
<div id="divFooterCenter" class="divFooterBox"></div>
|
||||||
|
<div id="divFooterRight" class="divFooterBox"></div>
|
||||||
|
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
SIDENAV
|
||||||
|
================================================================
|
||||||
|
Slide-out navigation panel. Opens from left when hamburger clicked.
|
||||||
|
Uses flexbox layout to pin version bar to bottom.
|
||||||
|
Includes a version bar footer with theme toggle and logout buttons.
|
||||||
|
================================================================ -->
|
||||||
|
<div id="divSideNav">
|
||||||
|
<div id="divSideNavHeader">
|
||||||
|
<!-- No close button - use main hamburger to close -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divSideNavBody">
|
||||||
|
<div id="divFiles"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divAiSection" class="sidenavSection">
|
||||||
|
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
|
||||||
|
<div id="divAiList" class="sidenavSectionList">
|
||||||
|
<div id="divAiProvidersList">No saved providers yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="divRelaySection">
|
||||||
|
<div id="divRelaySectionTitle">
|
||||||
|
リレー
|
||||||
|
</div>
|
||||||
|
<div id="divRelayList">
|
||||||
|
Loading relays...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="divBlossomSection">
|
||||||
|
|
||||||
|
<div id="divBlossomSectionTitle">ブロッサム</div>
|
||||||
|
|
||||||
|
<div id="divBlossomList">Loading blossom servers...</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div id="divVersionBar">
|
||||||
|
<span id="versionDisplay">v0.0.1</span>
|
||||||
|
<div id="divVersionBarButtons">
|
||||||
|
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
|
||||||
|
<div id="themeToggleHamburgerContainer"></div>
|
||||||
|
</button>
|
||||||
|
<button id="logoutButton" title="Logout">
|
||||||
|
<div id="logoutHamburgerContainer"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================================================
|
||||||
|
REQUIRED SCRIPTS
|
||||||
|
================================================================
|
||||||
|
These scripts must be loaded in this order:
|
||||||
|
1. nostr.bundle.js - Nostr tools library
|
||||||
|
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||||
|
================================================================ -->
|
||||||
|
<script src="./nostr.bundle.js"></script>
|
||||||
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
/* ================================================================
|
||||||
|
IMPORTS
|
||||||
|
================================================================
|
||||||
|
Import shared NDK functionality from init-ndk.mjs:
|
||||||
|
- initNDKPage() - Initialize authentication and worker
|
||||||
|
- getPubkey() - Get current user's pubkey
|
||||||
|
- subscribe() - Create NDK subscriptions
|
||||||
|
- publishEvent() - Publish events via NDK
|
||||||
|
- disconnect() - Disconnect from worker
|
||||||
|
- getRelayData() - Get relay connection data
|
||||||
|
- getRelayStats() - Get relay activity statistics
|
||||||
|
|
||||||
|
Import HamburgerMorphing for animated icons
|
||||||
|
================================================================ */
|
||||||
|
import {
|
||||||
|
initNDKPage,
|
||||||
|
getPubkey, injectHeaderAvatar, injectHeaderLoginButton,
|
||||||
|
subscribe,
|
||||||
|
publishEvent,
|
||||||
|
disconnect,
|
||||||
|
getVersion,
|
||||||
|
updateVersionDisplay,
|
||||||
|
getUserSettings,
|
||||||
|
patchUserSettings,
|
||||||
|
onUserSettings
|
||||||
|
} from './js/init-ndk.mjs';
|
||||||
|
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||||
|
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||||
|
|
||||||
|
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||||
|
|
||||||
|
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||||
|
// Version will be loaded asynchronously
|
||||||
|
const versionInfo = await getVersion();
|
||||||
|
const VERSION = versionInfo.VERSION;
|
||||||
|
console.log(`[fips-directory ${VERSION}] Loading...`);
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
GLOBAL VARIABLES
|
||||||
|
================================================================
|
||||||
|
Track state for hamburger menu, relay status, and theme.
|
||||||
|
================================================================ */
|
||||||
|
let updateIntervalId = null;
|
||||||
|
let currentPubkey = null;
|
||||||
|
|
||||||
|
// FIPS directory state
|
||||||
|
let bookmarks = new Map(); // eventId -> { id, pubkey, url, title, description, createdAt }
|
||||||
|
let blocklist = new Set(); // Set of hex pubkeys blocked by current user
|
||||||
|
let myMuteListEventId = null; // event id of current user's kind 10000
|
||||||
|
let bookmarksLoaded = false;
|
||||||
|
let blocklistLoaded = false;
|
||||||
|
let bookmarkSub = null;
|
||||||
|
let muteListSub = null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
AUTH STATE MODEL
|
||||||
|
------------------------------------------------------------------
|
||||||
|
This page uses optional auth: anyone can view the directory, but
|
||||||
|
login is needed to add/remove links and block publishers.
|
||||||
|
|
||||||
|
- optional (default):
|
||||||
|
Page can render public/read-only data without login, but can still
|
||||||
|
prompt login later for user actions (publish, settings, etc).
|
||||||
|
|
||||||
|
URL behavior:
|
||||||
|
- If ?auth=required|optional|none is present, it wins.
|
||||||
|
- Otherwise, defaults to optional.
|
||||||
|
*/
|
||||||
|
let isAuthenticated = false;
|
||||||
|
let authMode = 'optional';
|
||||||
|
let authedPageInitialized = false;
|
||||||
|
let relayActivityListenersBound = false;
|
||||||
|
|
||||||
|
// Hamburger menu
|
||||||
|
let hamburgerInstance = null;
|
||||||
|
let isNavOpen = false;
|
||||||
|
|
||||||
|
// Version bar buttons
|
||||||
|
let logoutHamburger = null;
|
||||||
|
let themeToggleHamburger = null;
|
||||||
|
let isDarkMode = false;
|
||||||
|
|
||||||
|
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
|
||||||
|
let pageSettings = {};
|
||||||
|
let unsubscribeUserSettings = null;
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
DOM VARIABLES
|
||||||
|
================================================================
|
||||||
|
Cache DOM element references for better performance.
|
||||||
|
================================================================ */
|
||||||
|
const divBody = document.getElementById("divBody");
|
||||||
|
const divSideNav = document.getElementById("divSideNav");
|
||||||
|
const divSideNavBody = document.getElementById("divSideNavBody");
|
||||||
|
const divFooterCenter = document.getElementById("divFooterCenter");
|
||||||
|
const divFooterRight = document.getElementById("divFooterRight");
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
HAMBURGER MENU
|
||||||
|
================================================================
|
||||||
|
Initialize and control the animated hamburger menu.
|
||||||
|
================================================================ */
|
||||||
|
function initHamburgerMenu() {
|
||||||
|
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||||
|
foreground: 'var(--primary-color)',
|
||||||
|
background: 'var(--secondary-color)',
|
||||||
|
hover: 'var(--accent-color)'
|
||||||
|
});
|
||||||
|
hamburgerInstance.animateTo('burger');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
SIDENAV FUNCTIONS
|
||||||
|
================================================================
|
||||||
|
Open/close sidenav with hamburger morphing animation.
|
||||||
|
================================================================ */
|
||||||
|
function openNav() {
|
||||||
|
divSideNav.style.zIndex = 3;
|
||||||
|
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
||||||
|
isNavOpen = true;
|
||||||
|
if (hamburgerInstance) {
|
||||||
|
hamburgerInstance.animateTo('arrow_left');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize version bar buttons when sidenav opens (lazy load)
|
||||||
|
if (!logoutHamburger) {
|
||||||
|
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||||||
|
size: 24,
|
||||||
|
foreground: 'var(--primary-color)',
|
||||||
|
background: 'var(--secondary-color)',
|
||||||
|
hover: 'var(--accent-color)'
|
||||||
|
});
|
||||||
|
logoutHamburger.animateTo('x');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!themeToggleHamburger) {
|
||||||
|
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
|
||||||
|
size: 24,
|
||||||
|
foreground: 'var(--primary-color)',
|
||||||
|
background: 'var(--secondary-color)',
|
||||||
|
hover: 'var(--accent-color)'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Determine current theme
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||||||
|
const initialShape = isDarkMode ? 'moon' : 'circle';
|
||||||
|
themeToggleHamburger.animateTo(initialShape);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNav() {
|
||||||
|
divSideNav.style.width = "0vw";
|
||||||
|
divSideNav.style.zIndex = -1;
|
||||||
|
isNavOpen = false;
|
||||||
|
if (hamburgerInstance) {
|
||||||
|
hamburgerInstance.animateTo('burger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleNav() {
|
||||||
|
if (isNavOpen) {
|
||||||
|
closeNav();
|
||||||
|
} else {
|
||||||
|
openNav();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
AUTH MODE HELPERS
|
||||||
|
================================================================ */
|
||||||
|
function hasTargetPubkeyInUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
const npub = String(params.get('npub') || '').trim();
|
||||||
|
const pubkey = String(params.get('pubkey') || '').trim();
|
||||||
|
return Boolean(npub || pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthModeFromUrl() {
|
||||||
|
const params = new URLSearchParams(window.location.search || '');
|
||||||
|
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
|
||||||
|
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
|
||||||
|
return explicitAuth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convention: explicit target profiles are public-readable by default.
|
||||||
|
if (hasTargetPubkeyInUrl()) {
|
||||||
|
return 'optional';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'optional';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthRequiredError(error) {
|
||||||
|
const message = String(error?.message || error || '').toLowerCase();
|
||||||
|
return message.includes('authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeAuthentication(mode) {
|
||||||
|
// required: existing behavior, throw if auth fails.
|
||||||
|
if (mode === 'required') {
|
||||||
|
await initNDKPage();
|
||||||
|
currentPubkey = await getPubkey();
|
||||||
|
isAuthenticated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// none: public page, no login attempt on load.
|
||||||
|
if (mode === 'none') {
|
||||||
|
isAuthenticated = false;
|
||||||
|
currentPubkey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// optional: try silent/normal init; if auth required, continue public.
|
||||||
|
try {
|
||||||
|
await initNDKPage();
|
||||||
|
currentPubkey = await getPubkey();
|
||||||
|
isAuthenticated = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isAuthRequiredError(error)) {
|
||||||
|
console.log('[fips-directory] Optional auth mode: continuing unauthenticated');
|
||||||
|
isAuthenticated = false;
|
||||||
|
currentPubkey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeAuthenticatedPageFeatures() {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
await injectHeaderLoginButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (authedPageInitialized) return;
|
||||||
|
|
||||||
|
await injectHeaderAvatar(currentPubkey);
|
||||||
|
console.log('[fips-directory] Authenticated as:', currentPubkey);
|
||||||
|
|
||||||
|
// Hydrate app-wide user settings for this page
|
||||||
|
try {
|
||||||
|
pageSettings = await getUserSettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[fips-directory] getUserSettings failed:', error);
|
||||||
|
pageSettings = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to live user settings updates (cross-tab + publish echoes)
|
||||||
|
if (!unsubscribeUserSettings) {
|
||||||
|
unsubscribeUserSettings = onUserSettings((settings) => {
|
||||||
|
pageSettings = settings || {};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize relay-dependent UI only once authenticated.
|
||||||
|
initFooterRelayStatus();
|
||||||
|
initSidenavRelaySection();
|
||||||
|
await initBlossomSection();
|
||||||
|
initAiSectionWithLocalConfig();
|
||||||
|
await UpdateFooter();
|
||||||
|
|
||||||
|
if (!updateIntervalId) {
|
||||||
|
updateIntervalId = setInterval(UpdateFooter, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay activity listeners only matter after worker init/auth.
|
||||||
|
if (!relayActivityListenersBound) {
|
||||||
|
window.addEventListener('ndkRelayActivity', (event) => {
|
||||||
|
const { relayUrl, activity, stats } = event.detail;
|
||||||
|
console.log(`[fips-directory] Relay activity: ${relayUrl} - ${activity}`, stats);
|
||||||
|
setRelayActivityState(relayUrl, activity);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'relayActivity') {
|
||||||
|
const { relayUrl, activity } = event.data;
|
||||||
|
console.log(`[fips-directory] Relay activity: ${relayUrl} - ${activity}`);
|
||||||
|
setRelayActivityState(relayUrl, activity);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
relayActivityListenersBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
authedPageInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptLoginIfNeeded() {
|
||||||
|
if (isAuthenticated) return true;
|
||||||
|
|
||||||
|
await initNDKPage();
|
||||||
|
currentPubkey = await getPubkey();
|
||||||
|
isAuthenticated = true;
|
||||||
|
await initializeAuthenticatedPageFeatures();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
UPDATE FOOTER
|
||||||
|
================================================================
|
||||||
|
Update footer sections with relay status, pubkey, and other info.
|
||||||
|
Called periodically by update loop.
|
||||||
|
================================================================ */
|
||||||
|
const UpdateFooter = async () => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update relay status visuals in footer and sidenav
|
||||||
|
await updateFooterRelayStatus();
|
||||||
|
await updateSidenavRelaySection();
|
||||||
|
|
||||||
|
await updateBlossomSection();
|
||||||
|
// Clear center and right sections
|
||||||
|
divFooterCenter.innerHTML = '';
|
||||||
|
divFooterRight.innerHTML = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[fips-directory] Error updating footer:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
ESCAPE HELPER
|
||||||
|
================================================================ */
|
||||||
|
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
TAG VALUE HELPER
|
||||||
|
================================================================ */
|
||||||
|
function getTagValue(tags, name) {
|
||||||
|
const tag = tags.find(t => t[0] === name);
|
||||||
|
return tag && tag.length > 1 ? tag[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
FIPS BOOKMARK FUNCTIONS (NIP-B0, kind 39701)
|
||||||
|
================================================================ */
|
||||||
|
function parseBookmark(evt) {
|
||||||
|
const tags = evt.tags || [];
|
||||||
|
const dTag = getTagValue(tags, 'd') || '';
|
||||||
|
const title = getTagValue(tags, 'title') || dTag;
|
||||||
|
const url = dTag;
|
||||||
|
const description = evt.content || '';
|
||||||
|
return { id: evt.id, pubkey: evt.pubkey, url, title, description, createdAt: evt.created_at || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeFipsBookmarks() {
|
||||||
|
console.log('[fips-directory] Subscribing to kind 39701 bookmarks...');
|
||||||
|
bookmarkSub = subscribe(
|
||||||
|
{ kinds: [39701], '#t': ['fips-directory'], limit: 1000 },
|
||||||
|
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initBookmarkListener() {
|
||||||
|
window.addEventListener('ndkEvent', (event) => {
|
||||||
|
const evt = event.detail;
|
||||||
|
if (evt.kind !== 39701) return;
|
||||||
|
const tags = evt.tags || [];
|
||||||
|
if (!tags.some(t => t[0] === 't' && t[1] === 'fips-directory')) return;
|
||||||
|
// Skip if we already have this event
|
||||||
|
if (bookmarks.has(evt.id)) return;
|
||||||
|
bookmarks.set(evt.id, parseBookmark(evt));
|
||||||
|
console.log('[fips-directory] Added bookmark:', bookmarks.get(evt.id).title);
|
||||||
|
if (bookmarksLoaded) renderDirectory();
|
||||||
|
});
|
||||||
|
window.addEventListener('ndkEose', (event) => {
|
||||||
|
if (bookmarkSub && event.detail && event.detail.subId !== bookmarkSub.subId) return;
|
||||||
|
if (!bookmarksLoaded) { bookmarksLoaded = true; renderDirectory(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
MUTE LIST FUNCTIONS (NIP-51, kind 10000)
|
||||||
|
================================================================ */
|
||||||
|
function subscribeMuteLists() {
|
||||||
|
console.log('[fips-directory] Subscribing to kind 10000 mute lists...');
|
||||||
|
muteListSub = subscribe(
|
||||||
|
{ kinds: [10000], limit: 500 },
|
||||||
|
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMuteListListener() {
|
||||||
|
window.addEventListener('ndkEvent', (event) => {
|
||||||
|
const evt = event.detail;
|
||||||
|
if (evt.kind !== 10000) return;
|
||||||
|
// Only care about the current user's mute list
|
||||||
|
if (evt.pubkey !== currentPubkey) return;
|
||||||
|
const tags = evt.tags || [];
|
||||||
|
blocklist = new Set(tags.filter(t => t[0] === 'p').map(t => t[1]));
|
||||||
|
myMuteListEventId = evt.id;
|
||||||
|
console.log('[fips-directory] Blocklist updated:', blocklist.size, 'blocked pubkeys');
|
||||||
|
if (blocklistLoaded) renderDirectory();
|
||||||
|
});
|
||||||
|
window.addEventListener('ndkEose', (event) => {
|
||||||
|
if (muteListSub && event.detail && event.detail.subId !== muteListSub.subId) return;
|
||||||
|
if (!blocklistLoaded) { blocklistLoaded = true; renderDirectory(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
RENDER DIRECTORY
|
||||||
|
================================================================ */
|
||||||
|
function renderDirectory() {
|
||||||
|
window.renderDirectory = renderDirectory;
|
||||||
|
|
||||||
|
// Group bookmarks by pubkey
|
||||||
|
const byPubkey = {};
|
||||||
|
bookmarks.forEach((bm) => {
|
||||||
|
if (!byPubkey[bm.pubkey]) byPubkey[bm.pubkey] = [];
|
||||||
|
byPubkey[bm.pubkey].push(bm);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort pubkeys alphabetically by their first bookmark's title for stable order
|
||||||
|
const sortedPubkeys = Object.keys(byPubkey).sort((a, b) => {
|
||||||
|
const aName = byPubkey[a][0].title.toLowerCase();
|
||||||
|
const bName = byPubkey[b][0].title.toLowerCase();
|
||||||
|
return aName.localeCompare(bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add link form (only if logged in)
|
||||||
|
var addFormHtml = '';
|
||||||
|
if (isAuthenticated) {
|
||||||
|
addFormHtml =
|
||||||
|
'<div style="max-width:500px;margin-bottom:15px;border:1px solid var(--border-color);border-radius:var(--border-radius);padding:12px;">' +
|
||||||
|
'<div style="font-weight:bold;margin-bottom:10px;">Add Your FIPS Link</div>' +
|
||||||
|
'<div style="margin-bottom:8px;">' +
|
||||||
|
'<input id="fdLinkName" type="text" placeholder="Name (e.g. My Relay)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="margin-bottom:8px;">' +
|
||||||
|
'<input id="fdLinkUrl" type="text" placeholder="FIPS URL (e.g. http://npub1....fips/relay/)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div style="margin-bottom:8px;">' +
|
||||||
|
'<input id="fdLinkDesc" type="text" placeholder="Description (optional)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
|
||||||
|
'</div>' +
|
||||||
|
'<div><button class="btn" onclick="doAddLink()">Add Link</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build publisher sections
|
||||||
|
var contentHtml = '';
|
||||||
|
if (sortedPubkeys.length === 0) {
|
||||||
|
contentHtml = '<div style="text-align:center;padding:20px;color:var(--muted-color);">No FIPS links yet. ' +
|
||||||
|
(isAuthenticated ? 'Add one above!' : 'Sign in to add your links.') + '</div>';
|
||||||
|
} else {
|
||||||
|
sortedPubkeys.forEach(function(pubkey) {
|
||||||
|
var bms = byPubkey[pubkey];
|
||||||
|
var isOwn = isAuthenticated && pubkey === currentPubkey;
|
||||||
|
var isBlocked = blocklist.has(pubkey);
|
||||||
|
|
||||||
|
// Skip blocked publishers (unless it's the current user)
|
||||||
|
if (isBlocked && !isOwn) return;
|
||||||
|
|
||||||
|
// Publisher header
|
||||||
|
var headerHtml =
|
||||||
|
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);margin-bottom:10px;">' +
|
||||||
|
'<div style="font-weight:bold;font-size:14px;padding:10px 12px;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center;">' +
|
||||||
|
'<span>' + esc(pubkey.slice(0, 12) + '...' + pubkey.slice(-8)) + ' <span style="font-size:12px;color:var(--muted-color);">(' + bms.length + ')</span></span>' +
|
||||||
|
'<span>';
|
||||||
|
|
||||||
|
// Block/unblock button (only for other publishers, when logged in)
|
||||||
|
if (isAuthenticated && !isOwn) {
|
||||||
|
if (isBlocked) {
|
||||||
|
headerHtml += '<button class="btn" style="font-size:11px;padding:4px 8px;" onclick="doToggleBlock(\'' + pubkey + '\')">Unblock</button>';
|
||||||
|
} else {
|
||||||
|
headerHtml += '<button class="btn" style="font-size:11px;padding:4px 8px;" onclick="doToggleBlock(\'' + pubkey + '\')">Block</button>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headerHtml += '</span></div>';
|
||||||
|
|
||||||
|
// Link items
|
||||||
|
bms.forEach(function(bm) {
|
||||||
|
headerHtml +=
|
||||||
|
'<div style="border-bottom:1px solid var(--border-color);font-size:12px;">' +
|
||||||
|
'<div style="padding:8px 12px;display:flex;justify-content:space-between;align-items:center;">' +
|
||||||
|
'<div style="flex:1;min-width:0;">' +
|
||||||
|
'<div><a href="' + esc(bm.url) + '" target="_blank" rel="noopener" style="font-weight:bold;color:var(--accent-color);text-decoration:none;">' + esc(bm.title) + '</a></div>' +
|
||||||
|
(bm.description ? '<div style="color:var(--muted-color);font-size:11px;margin-top:2px;">' + esc(bm.description) + '</div>' : '') +
|
||||||
|
'<div style="color:var(--muted-color);font-size:10px;margin-top:2px;word-break:break-all;">' + esc(bm.url) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
(isOwn ? '<button class="btn" style="font-size:11px;padding:4px 8px;margin-left:8px;flex-shrink:0;" onclick="doRemoveLink(\'' + bm.id + '\')">Remove</button>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
headerHtml += '</div>';
|
||||||
|
contentHtml += headerHtml;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
divBody.innerHTML = '<div>' + addFormHtml + contentHtml + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
ADD LINK (publish kind 39701)
|
||||||
|
================================================================ */
|
||||||
|
async function doAddLink() {
|
||||||
|
if (!isAuthenticated) { var ok = await promptLoginIfNeeded(); if (!ok) { setStatus('Sign in to add links.'); return; } }
|
||||||
|
var name = document.getElementById('fdLinkName').value.trim();
|
||||||
|
var url = document.getElementById('fdLinkUrl').value.trim();
|
||||||
|
var desc = document.getElementById('fdLinkDesc').value.trim();
|
||||||
|
if (!name) { setStatus('Enter a name for the link.'); return; }
|
||||||
|
if (!url) { setStatus('Enter a FIPS URL.'); return; }
|
||||||
|
try {
|
||||||
|
var result = await publishEvent({
|
||||||
|
kind: 39701,
|
||||||
|
content: desc,
|
||||||
|
tags: [
|
||||||
|
['d', url],
|
||||||
|
['title', name],
|
||||||
|
['t', 'fips-directory'],
|
||||||
|
['published_at', String(Math.floor(Date.now() / 1000))]
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
var n = (result?.relayResults?.successful || []).length;
|
||||||
|
setStatus('Link published to ' + n + ' relay(s)');
|
||||||
|
document.getElementById('fdLinkName').value = '';
|
||||||
|
document.getElementById('fdLinkUrl').value = '';
|
||||||
|
document.getElementById('fdLinkDesc').value = '';
|
||||||
|
} catch (e) { setStatus('Publish failed: ' + e.message); }
|
||||||
|
}
|
||||||
|
window.doAddLink = doAddLink;
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
REMOVE LINK (NIP-09 kind 5 deletion)
|
||||||
|
================================================================ */
|
||||||
|
async function doRemoveLink(eventId) {
|
||||||
|
if (!isAuthenticated) { var ok = await promptLoginIfNeeded(); if (!ok) return; }
|
||||||
|
try {
|
||||||
|
var result = await publishEvent({
|
||||||
|
kind: 5,
|
||||||
|
content: 'Removed from FIPS directory',
|
||||||
|
tags: [
|
||||||
|
['e', eventId],
|
||||||
|
['k', '39701']
|
||||||
|
],
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
var n = (result?.relayResults?.successful || []).length;
|
||||||
|
setStatus('Link removed from ' + n + ' relay(s)');
|
||||||
|
bookmarks.delete(eventId);
|
||||||
|
renderDirectory();
|
||||||
|
} catch (e) { setStatus('Remove failed: ' + e.message); }
|
||||||
|
}
|
||||||
|
window.doRemoveLink = doRemoveLink;
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
TOGGLE BLOCK (NIP-51 kind 10000 mute list)
|
||||||
|
================================================================ */
|
||||||
|
async function doToggleBlock(pubkey) {
|
||||||
|
if (!isAuthenticated) { var ok = await promptLoginIfNeeded(); if (!ok) return; }
|
||||||
|
var isBlocked = blocklist.has(pubkey);
|
||||||
|
var newTags = [];
|
||||||
|
|
||||||
|
// Copy existing p tags, excluding the target pubkey if unblocking
|
||||||
|
blocklist.forEach(function(p) {
|
||||||
|
if (p !== pubkey) newTags.push(['p', p]);
|
||||||
|
});
|
||||||
|
if (!isBlocked) {
|
||||||
|
newTags.push(['p', pubkey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var result = await publishEvent({
|
||||||
|
kind: 10000,
|
||||||
|
content: '',
|
||||||
|
tags: newTags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
var n = (result?.relayResults?.successful || []).length;
|
||||||
|
setStatus((isBlocked ? 'Unblocked' : 'Blocked') + ' — published to ' + n + ' relay(s)');
|
||||||
|
// Update local state immediately
|
||||||
|
if (isBlocked) {
|
||||||
|
blocklist.delete(pubkey);
|
||||||
|
} else {
|
||||||
|
blocklist.add(pubkey);
|
||||||
|
}
|
||||||
|
myMuteListEventId = result?.eventId || myMuteListEventId;
|
||||||
|
renderDirectory();
|
||||||
|
} catch (e) { setStatus('Blocklist update failed: ' + e.message); }
|
||||||
|
}
|
||||||
|
window.doToggleBlock = doToggleBlock;
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
divFooterCenter.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
LOGOUT
|
||||||
|
================================================================
|
||||||
|
Complete logout process:
|
||||||
|
1. Stop update loop
|
||||||
|
2. Disconnect from NDK worker
|
||||||
|
3. Logout from nostr-login-lite
|
||||||
|
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
|
||||||
|
5. Reload page
|
||||||
|
================================================================ */
|
||||||
|
const Logout = async () => {
|
||||||
|
console.log("[fips-directory] Starting logout process...");
|
||||||
|
|
||||||
|
// Stop the update loop
|
||||||
|
if (updateIntervalId) {
|
||||||
|
clearInterval(updateIntervalId);
|
||||||
|
updateIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect from worker
|
||||||
|
disconnect();
|
||||||
|
|
||||||
|
// Logout from nostr-login-lite
|
||||||
|
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
||||||
|
await window.NOSTR_LOGIN_LITE.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all storage
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
// Clear IndexedDB
|
||||||
|
if (window.indexedDB) {
|
||||||
|
const databases = await window.indexedDB.databases();
|
||||||
|
for (const db of databases) {
|
||||||
|
if (db.name) {
|
||||||
|
window.indexedDB.deleteDatabase(db.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[fips-directory] Logged out, reloading page");
|
||||||
|
location.reload(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
INITIALIZATION
|
||||||
|
================================================================
|
||||||
|
Main initialization sequence:
|
||||||
|
1. Initialize hamburger menu
|
||||||
|
2. Set up hamburger click handler
|
||||||
|
3. Resolve auth mode from URL/query policy
|
||||||
|
4. Initialize authentication based on mode
|
||||||
|
5. Initialize authenticated-only features (if signed in)
|
||||||
|
6. Set up version bar button listeners
|
||||||
|
7. Subscribe to FIPS bookmarks and mute lists
|
||||||
|
8. Update version display
|
||||||
|
================================================================ */
|
||||||
|
(async function main() {
|
||||||
|
console.log("[fips-directory] Starting initialization...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize hamburger menu first
|
||||||
|
initHamburgerMenu();
|
||||||
|
|
||||||
|
// Add click handler to hamburger
|
||||||
|
const divSvgHam = document.getElementById('divSvgHam');
|
||||||
|
if (divSvgHam) {
|
||||||
|
divSvgHam.addEventListener('click', toggleNav);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize version bar buttons
|
||||||
|
const themeToggleButton = document.getElementById('themeToggleButton');
|
||||||
|
const logoutButton = document.getElementById('logoutButton');
|
||||||
|
|
||||||
|
if (themeToggleButton) {
|
||||||
|
themeToggleButton.addEventListener('click', () => {
|
||||||
|
isDarkMode = !isDarkMode;
|
||||||
|
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
|
||||||
|
document.documentElement.classList.toggle('dark-mode', isDarkMode);
|
||||||
|
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||||
|
if (themeToggleHamburger) {
|
||||||
|
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoutButton) {
|
||||||
|
logoutButton.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
// In optional/none modes this doubles as a "Sign in" entry point.
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
await promptLoginIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Logout();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout/login action failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve and initialize page auth policy.
|
||||||
|
authMode = resolveAuthModeFromUrl();
|
||||||
|
console.log('[fips-directory] Resolved auth mode:', authMode);
|
||||||
|
await initializeAuthentication(authMode);
|
||||||
|
|
||||||
|
// Initialize authenticated features only when signed in.
|
||||||
|
await initializeAuthenticatedPageFeatures();
|
||||||
|
|
||||||
|
// Set up bookmark listener and subscription (public + authenticated)
|
||||||
|
initBookmarkListener();
|
||||||
|
subscribeFipsBookmarks();
|
||||||
|
|
||||||
|
// Set up mute list listener and subscription (for blocklist)
|
||||||
|
initMuteListListener();
|
||||||
|
subscribeMuteLists();
|
||||||
|
|
||||||
|
// Optional UX note for public mode pages.
|
||||||
|
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
||||||
|
divFooterCenter.textContent = 'Public mode';
|
||||||
|
divFooterRight.textContent = 'Sign in from side menu to add your links';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update version display
|
||||||
|
await updateVersionDisplay();
|
||||||
|
|
||||||
|
console.log('[fips-directory] Initialization complete');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[fips-directory] Initialization failed:', error);
|
||||||
|
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
|
||||||
|
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
|
||||||
|
<div style="font-size: 16px; color: #666;">${error.message}</div>
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
WORKER MESSAGE TYPES
|
||||||
|
================================================================
|
||||||
|
The NDK worker can send these message types:
|
||||||
|
|
||||||
|
1. 'response' - Response to init/subscribe/publish requests
|
||||||
|
- data.profile - User profile (from init)
|
||||||
|
- data.relays - User relays (from init)
|
||||||
|
- data.success - Publish success status
|
||||||
|
- data.relayResults - Relay publish results
|
||||||
|
|
||||||
|
2. 'event' - Nostr event from subscription
|
||||||
|
- Dispatched as 'ndkEvent' window event
|
||||||
|
- event.detail contains the Nostr event
|
||||||
|
|
||||||
|
3. 'eose' - End of stored events for subscription
|
||||||
|
- Dispatched as 'ndkEose' window event
|
||||||
|
- event.detail.subId contains subscription ID
|
||||||
|
|
||||||
|
4. 'signRequest' - Request to sign event/encrypt/decrypt
|
||||||
|
- Handled automatically by init-ndk.mjs
|
||||||
|
- Calls window.nostr methods and sends response
|
||||||
|
|
||||||
|
5. 'error' - Error from worker
|
||||||
|
- Logged to console automatically
|
||||||
|
|
||||||
|
6. 'relayActivity' - Relay read/write activity notification
|
||||||
|
- Dispatched as 'ndkRelayActivity' window event
|
||||||
|
- Used to animate relay status icons in footer
|
||||||
|
================================================================ */
|
||||||
|
|
||||||
|
/* ================================================================
|
||||||
|
DISTRIBUTED ARCHITECTURE NOTES
|
||||||
|
================================================================
|
||||||
|
Each page is independently accessible and self-contained:
|
||||||
|
|
||||||
|
1. Authentication persists via nostr-login-lite localStorage
|
||||||
|
- Login once on any page
|
||||||
|
- All other pages automatically authenticated
|
||||||
|
|
||||||
|
2. NDK SharedWorker is shared across all tabs/pages
|
||||||
|
- Single NDK instance manages all connections
|
||||||
|
- Subscriptions from all pages handled by one worker
|
||||||
|
- Events broadcast to all connected pages
|
||||||
|
|
||||||
|
3. Dexie cache is shared across all pages
|
||||||
|
- IndexedDB persists across sessions
|
||||||
|
- Cache-first queries are fast
|
||||||
|
- Reduces relay load
|
||||||
|
|
||||||
|
4. User settings are centralized and shared
|
||||||
|
- Worker hydrates kind 30078 (`d:user-settings`) on init
|
||||||
|
- Pages read via getUserSettings()
|
||||||
|
- Pages patch via patchUserSettings({ featureNamespace: ... })
|
||||||
|
- Pages subscribe via onUserSettings() for live updates
|
||||||
|
|
||||||
|
5. Each page can be distributed independently
|
||||||
|
- Copy template.html and customize
|
||||||
|
- No dependencies on other pages
|
||||||
|
- Works standalone or as part of suite
|
||||||
|
|
||||||
|
6. Message-based signer bridges worker and page
|
||||||
|
- Worker's NDK uses MessageBasedSigner
|
||||||
|
- Signer sends sign requests to page
|
||||||
|
- Page calls window.nostr.signEvent()
|
||||||
|
- Response sent back to worker
|
||||||
|
- NDK completes signing and publishing
|
||||||
|
|
||||||
|
7. Relay status visualization
|
||||||
|
- Footer left section shows connected relays
|
||||||
|
- Each relay has animated icon (HamburgerMorphing)
|
||||||
|
- Icons morph based on activity (read/write)
|
||||||
|
- Temporary animations show real-time activity
|
||||||
|
================================================================ */
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1706,6 +1706,62 @@ export function reconnectRelay(relayUrl) {
|
|||||||
console.log('[init-ndk] Requesting reconnect for relay:', 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<string[]>} 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
|
* Helper to disconnect worker
|
||||||
*/
|
*/
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"VERSION": "v0.7.99",
|
"VERSION": "v0.7.100",
|
||||||
"VERSION_NUMBER": "0.7.99",
|
"VERSION_NUMBER": "0.7.100",
|
||||||
"BUILD_DATE": "2026-08-04T20:24:55.694Z"
|
"BUILD_DATE": "2026-08-07T10:54:50.095Z"
|
||||||
}
|
}
|
||||||
|
|||||||
+200
-1
@@ -89719,6 +89719,15 @@ const RELAY_KEEPALIVE_INTERVAL_MS = 30000;
|
|||||||
const SUBSCRIPTION_DEDUP_MAX_IDS = 5000;
|
const SUBSCRIPTION_DEDUP_MAX_IDS = 5000;
|
||||||
const SUBSCRIPTION_DEDUP_PRUNE_COUNT = 1500;
|
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
|
// App-wide user settings state
|
||||||
const USER_SETTINGS_DB_NAME = 'ndk-shared-settings';
|
const USER_SETTINGS_DB_NAME = 'ndk-shared-settings';
|
||||||
const USER_SETTINGS_DB_VERSION = 2;
|
const USER_SETTINGS_DB_VERSION = 2;
|
||||||
@@ -90652,6 +90661,22 @@ function attachRelayEventListeners(relay) {
|
|||||||
status: relay.status,
|
status: relay.status,
|
||||||
...(lastClose || {})
|
...(lastClose || {})
|
||||||
}, 'relay');
|
}, '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) => {
|
relay.on('notice', (notice) => {
|
||||||
@@ -91005,6 +91030,23 @@ async function initNDK() {
|
|||||||
console.warn('[Worker] NDKRelayAuthPolicies not available, relay auth will not work');
|
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
|
// Set pubkey on signer
|
||||||
if (currentPubkey) {
|
if (currentPubkey) {
|
||||||
messageSigner.pubkey = 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 {
|
return {
|
||||||
url: relay?.url || normalizedUrl || url,
|
url: relay?.url || normalizedUrl || url,
|
||||||
status: relay?.status ?? 0, // 0 = DISCONNECTED
|
status: relay?.status ?? 0, // 0 = DISCONNECTED
|
||||||
connectionTime: connectedAt,
|
connectionTime: connectedAt,
|
||||||
type: type || 'both',
|
type: type || 'both',
|
||||||
lastError,
|
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
|
* Handle reconnect relay request
|
||||||
*/
|
*/
|
||||||
@@ -97330,6 +97513,22 @@ self.onconnect = (event) => {
|
|||||||
handleReconnectRelay(e.data.relayUrl, port);
|
handleReconnectRelay(e.data.relayUrl, port);
|
||||||
break;
|
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':
|
case 'disconnect':
|
||||||
handleDisconnect();
|
handleDisconnect();
|
||||||
break;
|
break;
|
||||||
|
|||||||
+71
-1
@@ -337,7 +337,7 @@
|
|||||||
/* ================================================================
|
/* ================================================================
|
||||||
IMPORTS
|
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 { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.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 reconnectingRelays = new Set(); // Track which relays are reconnecting
|
||||||
let currentRelayList = []; // Store current relay list with types
|
let currentRelayList = []; // Store current relay list with types
|
||||||
let pendingRemovalRelayUrl = null; // Two-click confirmation for relay deletion
|
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 addRelayCanRead = true;
|
||||||
let addRelayCanWrite = true;
|
let addRelayCanWrite = true;
|
||||||
let addRelayDraftValue = ''; // Preserve typed add-relay input across table refreshes
|
let addRelayDraftValue = ''; // Preserve typed add-relay input across table refreshes
|
||||||
@@ -436,11 +437,16 @@ const versionInfo = await getVersion();
|
|||||||
}
|
}
|
||||||
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
|
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
|
||||||
connectionHistoryEnabled = historyEnabled;
|
connectionHistoryEnabled = historyEnabled;
|
||||||
|
const outboxEnabled = localStorage.getItem('outboxModel') !== 'false'; // default: true
|
||||||
divSideNavBody.innerHTML = `
|
divSideNavBody.innerHTML = `
|
||||||
<div id="divRelaySettings" style="display:flex;align-items:center;padding:4px 10px;cursor:pointer;font-size:80%;color:var(--primary-color);">
|
<div id="divRelaySettings" style="display:flex;align-items:center;padding:4px 10px;cursor:pointer;font-size:80%;color:var(--primary-color);">
|
||||||
<span style="flex:1;">Show connection history</span>
|
<span style="flex:1;">Show connection history</span>
|
||||||
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
|
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="divOutboxSettings" style="display:flex;align-items:center;padding:4px 10px;cursor:pointer;font-size:80%;color:var(--primary-color);">
|
||||||
|
<span style="flex:1;">Outbox model</span>
|
||||||
|
<div id="divOutboxToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${outboxEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
|
||||||
|
</div>
|
||||||
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
|
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
|
||||||
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
|
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
|
||||||
<span style="flex:1;">Broadcast Relays
|
<span style="flex:1;">Broadcast Relays
|
||||||
@@ -501,6 +507,24 @@ const versionInfo = await getVersion();
|
|||||||
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
|
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.
|
// Wire up the Broadcast Relays collapsible section.
|
||||||
initBroadcastRelaysSection();
|
initBroadcastRelaysSection();
|
||||||
|
|
||||||
@@ -827,6 +851,7 @@ const versionInfo = await getVersion();
|
|||||||
html += '<thead><tr>';
|
html += '<thead><tr>';
|
||||||
html += '<th class="tblCol tblColCenter"></th>';
|
html += '<th class="tblCol tblColCenter"></th>';
|
||||||
html += '<th class="tblCol tblColLeft">Relay</th>';
|
html += '<th class="tblCol tblColLeft">Relay</th>';
|
||||||
|
html += '<th class="tblCol tblColCenter">Enabled</th>';
|
||||||
html += '<th class="tblCol tblColCenter">Connected</th>';
|
html += '<th class="tblCol tblColCenter">Connected</th>';
|
||||||
html += '<th class="tblCol tblColCenter">Read</th>';
|
html += '<th class="tblCol tblColCenter">Read</th>';
|
||||||
html += '<th class="tblCol tblColCenter">Write</th>';
|
html += '<th class="tblCol tblColCenter">Write</th>';
|
||||||
@@ -875,9 +900,16 @@ const versionInfo = await getVersion();
|
|||||||
|
|
||||||
const dmInboxCheckbox = isDmInboxRelayEnabled(relay.url) ? SVG_CHECKED : SVG_UNCHECKED;
|
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 += `<tr class="${rowClass}">`;
|
html += `<tr class="${rowClass}">`;
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-remove-relay-url="${relay.url}" style="cursor: pointer;">${removeIcon}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-remove-relay-url="${relay.url}" style="cursor: pointer;">${removeIcon}</td>`;
|
||||||
html += `<td class="tblCol tblColLeft tblColRelay" data-select-relay-url="${relayNormalizedUrl}" title="Click to show debug history for ${relay.url}" style="cursor: pointer;">${relay.url}</td>`;
|
html += `<td class="tblCol tblColLeft tblColRelay" data-select-relay-url="${relayNormalizedUrl}" title="Click to show debug history for ${relay.url}" style="cursor: pointer;">${relay.url}</td>`;
|
||||||
|
html += `<td class="tblCol tblColCenter divSvg" data-enable-relay-url="${relay.url}" title="Click to enable/disable relay" style="cursor: pointer;">${enabledCheckbox}</td>`;
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-relay-url="${relay.url}" style="cursor: pointer;">${statusIcon}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-relay-url="${relay.url}" style="cursor: pointer;">${statusIcon}</td>`;
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="read" style="cursor: pointer;">${readCheckbox}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="read" style="cursor: pointer;">${readCheckbox}</td>`;
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="write" style="cursor: pointer;">${writeCheckbox}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-relay-index="${i}" data-toggle-type="write" style="cursor: pointer;">${writeCheckbox}</td>`;
|
||||||
@@ -895,6 +927,7 @@ const versionInfo = await getVersion();
|
|||||||
html += '<td class="tblCol tblColCenter">-</td>';
|
html += '<td class="tblCol tblColCenter">-</td>';
|
||||||
html += '<td class="tblCol tblColLeft"><input id="txtNewRelayUrl" class="relayInput" type="text" placeholder="Add relay (example: relay.damus.io or wss://relay.damus.io)" title="Press Enter to add relay" /></td>';
|
html += '<td class="tblCol tblColLeft"><input id="txtNewRelayUrl" class="relayInput" type="text" placeholder="Add relay (example: relay.damus.io or wss://relay.damus.io)" title="Press Enter to add relay" /></td>';
|
||||||
html += '<td class="tblCol tblColCenter">-</td>';
|
html += '<td class="tblCol tblColCenter">-</td>';
|
||||||
|
html += '<td class="tblCol tblColCenter">-</td>';
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="read" style="cursor: pointer;">${addReadCheckbox}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="read" style="cursor: pointer;">${addReadCheckbox}</td>`;
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="write" style="cursor: pointer;">${addWriteCheckbox}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="write" style="cursor: pointer;">${addWriteCheckbox}</td>`;
|
||||||
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="dm-inbox" style="cursor: pointer;">${addDmInboxCheckbox}</td>`;
|
html += `<td class="tblCol tblColCenter divSvg" data-add-toggle-type="dm-inbox" style="cursor: pointer;">${addDmInboxCheckbox}</td>`;
|
||||||
@@ -923,6 +956,14 @@ const versionInfo = await getVersion();
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Add click handlers to read/write toggle checkboxes
|
||||||
document.querySelectorAll('.divSvg[data-relay-index]').forEach(cell => {
|
document.querySelectorAll('.divSvg[data-relay-index]').forEach(cell => {
|
||||||
cell.addEventListener('click', () => {
|
cell.addEventListener('click', () => {
|
||||||
@@ -1456,6 +1497,30 @@ const versionInfo = await getVersion();
|
|||||||
}, 5000);
|
}, 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
|
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
|
// Stop the worker from broadcasting relay events when the page is closing
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (connectionHistoryEnabled) {
|
if (connectionHistoryEnabled) {
|
||||||
|
|||||||
Reference in New Issue
Block a user