Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a17bef1ac | ||
|
|
04405da933 | ||
|
|
ee0077063d | ||
|
|
b09bf0d839 | ||
|
|
d04844cce8 | ||
|
|
b03b848909 | ||
|
|
9b746838c3 | ||
|
|
978e5029f3 | ||
|
|
011e4c303c | ||
|
|
fa019fe9a1 | ||
|
|
0f31e1c301 | ||
|
|
c9a20e63b5 | ||
|
|
79e38b8f79 | ||
|
|
7cfd43b91b | ||
|
|
777ab312a5 | ||
|
|
2763dd9d6a | ||
|
|
a20b28cd21 | ||
|
|
456f0e1f2e | ||
|
|
4abb4bf33c | ||
|
|
7dcfc7c3e3 | ||
|
|
07f5f39f35 | ||
|
|
10ddda633c | ||
|
|
c55a5ebfd8 | ||
|
|
b6e945f8de | ||
|
|
3ec585273f | ||
|
|
0e8229e13e | ||
|
|
230bd70e07 | ||
|
|
c36cb4a0fe | ||
|
|
d6b167a50b | ||
|
|
27f4cd6857 | ||
|
|
28c8109f8a | ||
|
|
00e93b2b2f | ||
|
|
2334f742cf | ||
|
|
8c456ec522 | ||
|
|
e734f33542 | ||
|
|
6ad2d37a23 | ||
|
|
28ce7bce1b | ||
|
|
894a163111 | ||
|
|
efe9d977cd | ||
|
|
bff0bca411 | ||
|
|
fee3ffc764 | ||
|
|
d49a3e800f | ||
|
|
9d9d0f88f5 | ||
|
|
b11161862a | ||
|
|
fe35b2dfa1 | ||
|
|
ae67e65969 | ||
|
|
19344942b0 | ||
|
|
c758333269 | ||
|
|
3ed0c35611 | ||
|
|
d9503c1532 | ||
|
|
fa0859dcdd | ||
|
|
0c340316ae | ||
|
|
313a55992f | ||
|
|
ece3a136c1 | ||
|
|
543ae6a1b3 | ||
|
|
508952a1ea | ||
|
|
15ef106eb5 | ||
|
|
a42edcf6d6 | ||
|
|
0267233f87 | ||
|
|
6b45092bc8 | ||
|
|
6d39188967 | ||
|
|
c5f682c5fe | ||
|
|
073fb9e9af | ||
|
|
2f45c78741 | ||
|
|
6d41680297 | ||
|
|
b2a50376df | ||
|
|
3c4be89ead | ||
|
|
89d2c259f6 | ||
|
|
7b77da7349 | ||
|
|
38661b7fb6 | ||
|
|
eed00f67cf | ||
|
|
8998c5664d | ||
|
|
e943870d77 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
# GPT-2 model files (271MB, too large for git — uploaded directly to server)
|
||||
www/models/
|
||||
|
||||
hamburger_morphing/
|
||||
client/
|
||||
ndk/
|
||||
|
||||
@@ -51,7 +51,7 @@ Routes are parsed by splitting the hash on `/`:
|
||||
```
|
||||
#/playlist/abc123/ep-42 → { page: 'playlist', parts: ['abc123', 'ep-42'] }
|
||||
#/search/bob marley → { page: 'search', parts: ['bob marley'] }
|
||||
#/track/98765 → { page: 'track', parts: ['98765'] }
|
||||
#/track/36787:pubkey:dTag → { page: 'track', parts: ['36787:pubkey:dTag'] }
|
||||
```
|
||||
|
||||
## URL Examples
|
||||
@@ -73,8 +73,8 @@ vj.html?npub=npub1abc123...&show=saturday-reggae&episode=ep-1744382400
|
||||
|
||||
### Direct track link (works on either page)
|
||||
```
|
||||
music.html#/track/12345678
|
||||
vj.html#/track/12345678
|
||||
music.html#/track/36787:aaaaaaaa...:my-track-dtag
|
||||
vj.html#/track/36787:aaaaaaaa...:my-track-dtag
|
||||
```
|
||||
|
||||
### Search link
|
||||
@@ -95,7 +95,7 @@ music.html#/album/album-id-here
|
||||
| Select/create episode | `?episode={id}` added/updated | `replaceState` |
|
||||
| Search for music | `#/search/{query}` | hash assignment |
|
||||
| Click album/artist | `#/album/{id}` or `#/artist/{id}` | hash assignment |
|
||||
| Play track from link | `#/track/{id}` | hash assignment |
|
||||
| Play track from link | `#/track/{id}` (`id` is now typically `36787:pubkey:dTag`) | hash assignment |
|
||||
| Select playlist | `#/playlist/{id}` | hash assignment |
|
||||
| Login / auth change | `?npub={npub}` added | `replaceState` |
|
||||
| Load external user | `?npub={npub}` or `?pubkey={hex}` | page navigation |
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
|
||||
The music page ([`www/music.html`](www/music.html:1)) uses a **single unified queue** (`musicQueue[]`) as the source of truth for all playback. The audio engine ([`SimplePlayer`](www/js/greyscale-player.mjs:8)) is a pure playback component — it plays whatever stream URL it receives and fires callbacks when tracks end.
|
||||
|
||||
As of the Gruuv migration, track discovery/playback is relay-native:
|
||||
- Track metadata is read from Nostr events (`kind 36787`, `t=gruuv`) via [`GruuvAPI`](www/js/gruuv-api.mjs:333)
|
||||
- Playlist events are published/read as `kind 34139` via [`gruuv-playlists.mjs`](www/js/gruuv-playlists.mjs:1)
|
||||
- Track IDs in queue/URLs are now addressable coordinates (`36787:pubkey:dTag`), not numeric Tidal IDs
|
||||
- Uploads use Blossom + `kind 24242` auth and publish `kind 36787` via [`uploadGruuvTrack()`](www/js/gruuv-upload.mjs:139)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
@@ -72,7 +78,7 @@ Persisted to localStorage under key `music-queue:{pubkey}`:
|
||||
{
|
||||
"currentIndex": 2,
|
||||
"items": [
|
||||
{ "id": 123, "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
|
||||
{ "id": "36787:<pubkey>:<dTag>", "title": "...", "artist": "...", "duration": 240, "cover": "...", "albumTitle": "..." }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
122
plans/broadcast-batching-fix.md
Normal file
122
plans/broadcast-batching-fix.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Broadcast Relay Batching Fix
|
||||
|
||||
## Problem
|
||||
|
||||
With 600+ broadcast relays, NDK's `fromRelayUrls` creates 630 temporary relays
|
||||
and adds them all to the pool simultaneously. `NDKRelaySet.publish()` then fires
|
||||
all 630 relay publish operations in parallel via `Promise.all`. Each relay that
|
||||
isn't connected calls `relay.connect()` which opens a WebSocket.
|
||||
|
||||
Browsers limit simultaneous WebSocket connections (practically ~50-100 at a
|
||||
time before connections queue up). With 630 simultaneous connection attempts,
|
||||
most relays never get a connection slot before the per-relay timeout expires.
|
||||
Result: only ~13/630 relays succeed.
|
||||
|
||||
## Solution: Chunked publishing
|
||||
|
||||
Instead of creating one giant `NDKRelaySet` with all 630 relays and calling
|
||||
`publish()` once, we split the relay URLs into **batches of 50** and publish
|
||||
to each batch sequentially. Each batch gets its own `NDKRelaySet.fromRelayUrls()`
|
||||
call, its own `publish()`, and its own set of live progress listeners.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[630 relay URLs] --> B[Split into batches of 50]
|
||||
B --> C[Batch 1: 50 relays]
|
||||
C --> D[publish to batch 1]
|
||||
D --> E{Batch 1 done?}
|
||||
E -->|yes| F[Batch 2: 50 relays]
|
||||
F --> G[publish to batch 2]
|
||||
G --> H{Batch 2 done?}
|
||||
H -->|yes| I[... continue ...]
|
||||
I --> J[Batch 13: 30 relays]
|
||||
J --> K[publish to batch 13]
|
||||
K --> L{All batches done?}
|
||||
L -->|yes| M[Final done event with total count]
|
||||
```
|
||||
|
||||
### Key design points
|
||||
|
||||
1. **Batch size: 50 relays** — small enough to stay within browser WebSocket
|
||||
limits, large enough to finish in a reasonable time. Each batch takes ~5-10s.
|
||||
|
||||
2. **Sequential batches** — wait for each batch to complete before starting the
|
||||
next. This keeps the total simultaneous connections at ~50, not 630.
|
||||
|
||||
3. **Live progress across batches** — the `publishedSoFar` and `failedSoFar`
|
||||
sets accumulate across all batches. The `broadcastProgress` events stream
|
||||
continuously, so the footer shows the count climbing from 0 to 630 across
|
||||
all batches.
|
||||
|
||||
4. **Per-relay timeout: 10s per batch** — each relay in a batch gets 10s to
|
||||
connect + publish. Since only 50 are competing for connections (not 630),
|
||||
this is plenty of time.
|
||||
|
||||
5. **Overall deadline: removed** — since batches are sequential and each has
|
||||
its own per-relay timeout, the overall time is naturally bounded at
|
||||
`numBatches × perBatchTimeout`. No need for a separate overall deadline.
|
||||
|
||||
6. **Connection timeout: 10s** — set on each relay in the batch, matching the
|
||||
publish timeout.
|
||||
|
||||
## Implementation (in `www/ndk-worker.js` `handlePublish`)
|
||||
|
||||
Replace the current single `NDKRelaySet.fromRelayUrls(allUrls, ndk)` + single
|
||||
`publish()` with a batched loop:
|
||||
|
||||
```js
|
||||
const BATCH_SIZE = 50;
|
||||
const PER_RELAY_TIMEOUT_MS = 10000;
|
||||
|
||||
if (isBroadcast) {
|
||||
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
|
||||
const batches = [];
|
||||
for (let i = 0; i < allUrls.length; i += BATCH_SIZE) {
|
||||
batches.push(allUrls.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
console.log(`[Worker] Broadcasting to ${allUrls.length} relays in ${batches.length} batches of ${BATCH_SIZE}`);
|
||||
|
||||
// Emit start event
|
||||
broadcast({ type: 'broadcastProgress', sessionId, eventId, eventKind, phase: 'start', total: allUrls.length, successful: 0, failed: 0, latestRelay: null });
|
||||
|
||||
// Attach listeners once — they accumulate across all batches
|
||||
ndkEvent.on('relay:published', (relay) => { ... publishedSoFar.add ... });
|
||||
ndkEvent.on('relay:publish:failed', (relay, err) => { ... failedSoFar.add ... });
|
||||
|
||||
// Publish to each batch sequentially
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
|
||||
// Set connection timeout on each relay in this batch
|
||||
for (const relay of batchSet.relays) {
|
||||
try { relay.connectionTimeout = PER_RELAY_TIMEOUT_MS; } catch (_) {}
|
||||
}
|
||||
try {
|
||||
await ndkEvent.publish(batchSet, PER_RELAY_TIMEOUT_MS, 1);
|
||||
} catch (e) {
|
||||
// Expected — some relays in this batch may fail
|
||||
}
|
||||
console.log(`[Worker] Batch ${i+1}/${batches.length} done: ${publishedSoFar.size}/${allUrls.length} total succeeded`);
|
||||
}
|
||||
|
||||
// Emit final done event
|
||||
broadcast({ type: 'broadcastProgress', sessionId, eventId, eventKind, phase: 'done', total: allUrls.length, successful: publishedSoFar.size, failed: failedSoFar.size + timedOutSoFar.size, relayUrls: Array.from(publishedSoFar) });
|
||||
// Clean up listeners
|
||||
ndkEvent.removeAllListeners('relay:published');
|
||||
ndkEvent.removeAllListeners('relay:publish:failed');
|
||||
}
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | Replace single publish with batched loop in `handlePublish` |
|
||||
|
||||
## Testing
|
||||
|
||||
1. Post with a user that has 600+ broadcast relays
|
||||
2. Watch the footer: count should climb steadily as batches complete
|
||||
3. Final count should be much higher than 13 (ideally 500+)
|
||||
4. Each batch of 50 should take ~5-10 seconds
|
||||
5. Total time for 630 relays: ~13 batches × ~10s = ~130 seconds
|
||||
713
plans/broadcast-relays-feature.md
Normal file
713
plans/broadcast-relays-feature.md
Normal file
@@ -0,0 +1,713 @@
|
||||
# Broadcast Relays Feature
|
||||
|
||||
## Goal
|
||||
|
||||
Allow the user to publish posts to **many more relays** than their standard
|
||||
NIP-65 outbox — potentially hundreds — by maintaining a separate "broadcast
|
||||
relay list" that is **unioned into every public publish** automatically.
|
||||
|
||||
This mirrors Amethyst's `BroadcastRelayListEvent` (NIP-51 kind 10088) and uses
|
||||
NDK's `NDKRelaySet.fromRelayUrls()` primitive, which the worker already
|
||||
exercises in `handlePublishRawToRelay`.
|
||||
|
||||
## Design decisions (confirmed with user)
|
||||
|
||||
1. **Always-on union** — broadcast relays are automatically unioned into every
|
||||
public publish. No per-post toggle. (Matches Amethyst's default.)
|
||||
2. **Manage on `relays.html`** — a new "Broadcast Relays" section alongside
|
||||
"Discovered Relays" for add/remove/save.
|
||||
3. **One-click seed from discovered relays** — a button on `relays.html` bulk-
|
||||
adds the discovered-relays URLs (from follows' kind 10002) to the 10088
|
||||
list. User stays in control of when it happens.
|
||||
4. **Persistent skip-marks in kind 10088** — when a relay fails to publish
|
||||
(auth-required, unreachable, etc.), it stays in the list but is marked with
|
||||
a separate `["x", url]` tag so we skip it on future publishes. See
|
||||
"Skip-mark coexistence with Amethyst" below.
|
||||
|
||||
## Background — what each codebase already does
|
||||
|
||||
### Amethyst (`~/lt/amethyst`)
|
||||
|
||||
- [`BroadcastRelayListEvent.kt`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/BroadcastRelayListEvent.kt:43)
|
||||
defines **kind 10088** — a NIP-51 `PrivateTagArrayEvent`. Relay URLs live as
|
||||
`["r", url]` tags inside a NIP-44-encrypted `content` blob (private list),
|
||||
with an optional public-tag variant. It is replaceable per pubkey
|
||||
(`d = ""`).
|
||||
- [`AccountOutboxRelayState.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/AccountOutboxRelayState.kt:41)
|
||||
**unions** the broadcast set into the outbox:
|
||||
`nip65Outbox + privateOutBox + localRelays + broadcastRelays`.
|
||||
- [`Account.kt:1266`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1266)
|
||||
gates inclusion with `wantsBroadcastRelays(event)` — public posts/reactions/
|
||||
reposts get the broadcast set; drafts, app settings, bookmarks, channels with
|
||||
their own relays, and DMs do **not**.
|
||||
- [`Account.kt:1715`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1715)
|
||||
`sendLiterallyEverywhere()` is a separate "publish to everything" path used
|
||||
only for profile/identity bootstrap events — **not** what we want for posts.
|
||||
|
||||
### This client (`/home/user/lt/client`)
|
||||
|
||||
- [`publishEvent`](www/js/init-ndk.mjs:629) → worker
|
||||
[`handlePublish`](www/ndk-worker.js:5810) → `ndkEvent.publish()` with **no
|
||||
relaySet** → NDK outbox routing sends only to the user's kind 10002 write
|
||||
relays.
|
||||
- [`publishRawEventToRelay`](www/js/init-ndk.mjs:688) → worker
|
||||
[`handlePublishRawToRelay`](www/ndk-worker.js:5983) already demonstrates the
|
||||
exact NDK primitive we need:
|
||||
```js
|
||||
NDKRelaySet.fromRelayUrls([resolvedRelayUrl], ndk)
|
||||
await ndkEvent.publish(targetRelaySet)
|
||||
```
|
||||
NDK's `fromRelayUrls` (see `ndk-core.bundle.js:9245`) accepts an **array**
|
||||
and uses **temporary relays** (`pool.useTemporaryRelay`) so hundreds of URLs
|
||||
work without permanently polluting the connection pool.
|
||||
- [`mute-list.mjs`](www/js/mute-list.mjs:41) establishes the
|
||||
NIP-44-encrypted-private-list pattern we will mirror:
|
||||
`configureMuteList({ nip44Encrypt, nip44Decrypt, publishEvent, getPubkey })`.
|
||||
- The post composer ([`post-composer.mjs:740`](www/js/post-composer.mjs:740))
|
||||
calls `onSubmit(text, {attachments})` → pages call
|
||||
`publishEvent({kind:1,...})`. No composer change is needed for the always-on
|
||||
design.
|
||||
- No kind 10088 / broadcast-relay concept exists yet.
|
||||
|
||||
## Skip-mark coexistence with Amethyst
|
||||
|
||||
Amethyst's [`RelayTag`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/tags/RelayTag.kt:28)
|
||||
only matches `tag[0] == "r"` and reads `tag[1]` — it **ignores** any third
|
||||
element. Its `updateRelayList` calls `tags.remove(RelayTag::match)` which only
|
||||
strips `r` tags, **preserving** any non-`r` tags through Amethyst's updates.
|
||||
|
||||
This means we can safely store skip-marks in the **same kind 10088 event** as
|
||||
a separate tag type that Amethyst will never touch:
|
||||
|
||||
```
|
||||
// Active broadcast relay (Amethyst reads this):
|
||||
["r", "wss://relay.example"]
|
||||
|
||||
// Skip-marked relay (Amethyst ignores — not an "r" tag):
|
||||
["x", "wss://private.relay.example", "auth-required", "1700000000"]
|
||||
// ^ ^ ^ ^
|
||||
// | url reason timestamp of failure
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Our client reads **both** `r` and `x` tags from kind 10088.
|
||||
- `r` tags = active broadcast relays (unioned into publishes).
|
||||
- `x` tags = skip-marked relays (kept in the list for visibility, but excluded
|
||||
from the publish relay set).
|
||||
- When Amethyst updates the event, it only touches `r` tags — our `x` tags
|
||||
survive untouched.
|
||||
- When our client updates the event, we preserve all existing `r` tags (so we
|
||||
don't clobber Amethyst's list) and only add/remove `x` tags.
|
||||
- A skip-marked relay can be manually un-skipped from the UI (moves it back to
|
||||
`r`), or auto-un-skipped if a retry succeeds.
|
||||
|
||||
**Why not a third element on the `r` tag?** Amethyst's `RelayTag.parse()`
|
||||
ignores `tag[2]`, so `["r", url, "skipped"]` would technically survive parsing.
|
||||
But Amethyst's `updateRelayList` does `tags.remove(RelayTag::match)` which
|
||||
removes **all** `r` tags before re-adding the new list — so any third-element
|
||||
annotation would be **wiped** on Amethyst's next save. A separate `x` tag is
|
||||
the only safe coexistence strategy.
|
||||
|
||||
## Auto-seed from discovered relays
|
||||
|
||||
Your client already computes the perfect seed source:
|
||||
[`handleGetDiscoveredRelays`](www/ndk-worker.js:6458) builds a
|
||||
`relayServesPubkeys` map from all follows' kind 10002 `write`/`both` relays,
|
||||
cached in the module-level `discoveredRelaysCache` variable, keyed by the
|
||||
user's kind 3 contact list. The "Discovered Relays (Outbox)" section on
|
||||
`relays.html` already displays this cache.
|
||||
|
||||
**No duplication** — the "Add all discovered relays" button reads the **same
|
||||
`discoveredRelaysCache`** variable already populated by
|
||||
`handleGetDiscoveredRelays`. No second query, no second cache. The two
|
||||
features serve different purposes:
|
||||
- **Discovered Relays** = read-only display of relays your follows use (for
|
||||
awareness, cached from their kind 10002 events).
|
||||
- **Broadcast Relays** = your persistent kind 10088 list of relays YOU will
|
||||
publish to (user-curated, unioned into every publish).
|
||||
|
||||
The seed button is the one-way bridge: discovered → broadcast. The button
|
||||
will:
|
||||
|
||||
1. Call the existing `getDiscoveredRelays` worker handler to get the cached
|
||||
list of `{url, servingPubkeys, ...}` objects.
|
||||
2. Extract just the URLs.
|
||||
3. Union them with the existing `r`-tag broadcast relays (skip any already
|
||||
present, skip any in the `x`-tag skip-mark set).
|
||||
4. Save the merged list to kind 10088.
|
||||
|
||||
This is a **one-click, user-initiated** action — no automatic background
|
||||
seeding. The user can re-run it after following new people to pick up their
|
||||
relays.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Page
|
||||
PC[post-composer.mjs] -->|publishEvent kind:1| INIT[init-ndk.mjs]
|
||||
REL[relays.html] -->|saveBroadcastRelays / seedFromDiscovered| INIT
|
||||
end
|
||||
INIT -->|publish / saveBroadcastRelays| W[ndk-worker.js]
|
||||
subgraph Worker
|
||||
W --> HP[handlePublish]
|
||||
HP --> WBR{wantsBroadcastRelays?}
|
||||
WBR -->|yes| FILTER[active r-tags minus x-tag skips]
|
||||
WBR -->|no| DEFAULT[ndkEvent.publish default]
|
||||
FILTER --> UNION[union outbox + active broadcast urls]
|
||||
UNION --> RS[NDKRelaySet.fromRelayUrls]
|
||||
RS --> PUB[ndkEvent.publish relaySet]
|
||||
PUB --> MARK[on failure: add x-tag skip-mark to 10088]
|
||||
end
|
||||
subgraph Nostr
|
||||
PUB --> R10002[Kind 10002 write relays]
|
||||
PUB --> R10088[Kind 10088 broadcast relays - hundreds]
|
||||
end
|
||||
W -.->|subscribe kind 10088| R10088
|
||||
R10088 -.->|live update| W
|
||||
```
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### 1. Worker: broadcast-relay state + hydration (`www/ndk-worker.js`)
|
||||
|
||||
- Add module-level state:
|
||||
```js
|
||||
let broadcastRelayUrls = new Set(); // active "r" tags
|
||||
let skippedRelayUrls = new Map(); // url -> { reason, timestamp } ("x" tags)
|
||||
let latest10088Event = null; // raw event for update-preserving saves
|
||||
```
|
||||
- On first init (after `fetchUserRelays`), fetch the user's kind 10088:
|
||||
```js
|
||||
const filter = { kinds: [10088], authors: [pubkey], limit: 1 };
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
// pick newest event → store as latest10088Event
|
||||
// Parse public tags: ["r", url] → broadcastRelayUrls
|
||||
// NIP-44-decrypt content → parse private tags:
|
||||
// ["r", url] → broadcastRelayUrls (Amethyst stores relays privately)
|
||||
// ["x", url, reason, timestamp] → skippedRelayUrls
|
||||
```
|
||||
Reuse the existing `nip44Decrypt` plumbing the worker already has for the
|
||||
mute list. If decryption fails or no event exists, leave both sets empty.
|
||||
- Subscribe to kind 10088 for the pubkey so live updates from other tabs/
|
||||
clients (including Amethyst) refresh both sets and broadcast a
|
||||
`broadcastRelaysUpdated` message to all ports (mirrors the existing
|
||||
`relay-types-updated` pattern at `ndk-worker.js:5177`).
|
||||
- Add a helper `getActiveBroadcastUrls()` that returns
|
||||
`Array.from(broadcastRelayUrls).filter(u => !skippedRelayUrls.has(u))` —
|
||||
this is what `handlePublish` will use.
|
||||
|
||||
### 2. Worker: `wantsBroadcastRelays` gate + union in `handlePublish`
|
||||
|
||||
Add a helper near `handlePublish` (`ndk-worker.js:5810`):
|
||||
|
||||
```js
|
||||
function wantsBroadcastRelays(event) {
|
||||
// Public, broadcast-worthy kinds. Mirror Amethyst's Account.kt:1217.
|
||||
const BROADCAST_KINDS = new Set([
|
||||
1, // text note
|
||||
6, // repost
|
||||
7, // reaction
|
||||
30023, // long-form article
|
||||
30024, // draft long-form
|
||||
30315, // status
|
||||
31123, // public skill
|
||||
31124, // private skill (still public event)
|
||||
10088, // broadcast relay list itself
|
||||
]);
|
||||
// Explicitly EXCLUDE: 4 (DM), 10002 (relay list), 10050 (DM inbox),
|
||||
// 30078 (app settings), 7375/17375 (wallet), 1059/13/14 (gift wrap/seal).
|
||||
return BROADCAST_KINDS.has(Number(event.kind));
|
||||
}
|
||||
```
|
||||
|
||||
Modify `handlePublish` after signing, before `ndkEvent.publish()`:
|
||||
|
||||
```js
|
||||
let targetRelaySet = null;
|
||||
const activeBroadcastUrls = getActiveBroadcastUrls(); // r-tags minus x-tag skips
|
||||
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
|
||||
|
||||
if (isBroadcast) {
|
||||
// Union the user's normal outbox write relays with the active broadcast set.
|
||||
const outboxWriteUrls = Array.from(relayTypes.entries())
|
||||
.filter(([_, t]) => t === 'write' || t === 'both')
|
||||
.map(([url]) => url);
|
||||
const allUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
|
||||
if (NDKRelaySet?.fromRelayUrls) {
|
||||
targetRelaySet = NDKRelaySet.fromRelayUrls(allUrls, ndk);
|
||||
console.log(`[Worker] Broadcasting to ${allUrls.length} relays ` +
|
||||
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
|
||||
`${skippedRelayUrls.size} skipped)`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Live broadcast progress streaming ---
|
||||
// NDK's NDKEvent emits 'relay:published' and 'relay:publish:failed' per relay
|
||||
// as each completes (see ndk-core.bundle.js:8530 and :8552). We attach
|
||||
// listeners BEFORE publish() and stream progress to the page via broadcast().
|
||||
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
|
||||
const publishedSoFar = new Set();
|
||||
const failedSoFar = new Set();
|
||||
const totalTarget = targetRelaySet ? targetRelaySet.relays.size : 0;
|
||||
|
||||
if (isBroadcast && totalTarget > 0) {
|
||||
// Announce the broadcast session to all pages.
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
eventKind: event.kind,
|
||||
eventId: ndkEvent.id || null,
|
||||
phase: 'start',
|
||||
total: totalTarget,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
latestRelay: null,
|
||||
});
|
||||
|
||||
ndkEvent.on('relay:published', (relay) => {
|
||||
publishedSoFar.add(relay.url);
|
||||
trackRelayWrite(relay.url);
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size,
|
||||
latestRelay: relay.url,
|
||||
});
|
||||
});
|
||||
|
||||
ndkEvent.on('relay:publish:failed', (relay, err) => {
|
||||
failedSoFar.add(relay.url);
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size,
|
||||
latestRelay: relay.url,
|
||||
latestError: err?.message || String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const relaySet = await ndkEvent.publish(targetRelaySet);
|
||||
|
||||
// Final broadcast progress message with complete results.
|
||||
if (isBroadcast && totalTarget > 0) {
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'done',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size,
|
||||
latestRelay: null,
|
||||
});
|
||||
// Clean up listeners.
|
||||
ndkEvent.removeAllListeners('relay:published');
|
||||
ndkEvent.removeAllListeners('relay:publish:failed');
|
||||
}
|
||||
```
|
||||
|
||||
`NDKRelaySet.fromRelayUrls` uses `pool.useTemporaryRelay` for URLs not already
|
||||
in the pool, so hundreds of broadcast relays connect transiently for the
|
||||
publish and do not interfere with the user's read subscriptions.
|
||||
|
||||
**Failure marking** — after `ndkEvent.publish()` returns, the existing code at
|
||||
[`ndk-worker.js:5856`](www/ndk-worker.js:5856) already computes
|
||||
`relayResults.failed` (connected relays that didn't accept the event). Extend
|
||||
it to persist skip-marks for broadcast relays that failed:
|
||||
|
||||
```js
|
||||
// After the existing relayResults.failed computation:
|
||||
if (isBroadcast) {
|
||||
const newSkips = relayResults.failed
|
||||
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
|
||||
if (newSkips.length > 0) {
|
||||
for (const url of newSkips) {
|
||||
skippedRelayUrls.set(url, {
|
||||
reason: 'publish-failed',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
// Persist the updated skip-marks to kind 10088 (debounced — see step 3).
|
||||
scheduleSkipMarkSave();
|
||||
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The skip-mark save is **debounced** (e.g. 5s) so a single post that fails on
|
||||
20 relays produces one kind 10088 republish, not 20. The save preserves all
|
||||
existing `r` tags and only updates the `x` tags (see step 3).
|
||||
|
||||
### 3. Worker: `getBroadcastRelays` + `saveBroadcastRelays` + `seedBroadcastRelays` + skip-mark handlers
|
||||
|
||||
Add new message cases in the worker's `switch` (near `ndk-worker.js:6980`):
|
||||
|
||||
- `getBroadcastRelays` → respond with:
|
||||
```js
|
||||
{
|
||||
active: Array.from(broadcastRelayUrls),
|
||||
skipped: Array.from(skippedRelayUrls.entries()).map(([url, info]) => ({ url, ...info })),
|
||||
}
|
||||
```
|
||||
|
||||
- `saveBroadcastRelays` → build/update a kind 10088 event. **Critical: preserve
|
||||
existing `r` tags and all `x` tags** so we coexist with Amethyst:
|
||||
```js
|
||||
// Start from latest10088Event if we have one (preserve Amethyst's r-tags
|
||||
// that the user might not have in our local set yet). Otherwise start fresh.
|
||||
const existingRTags = latest10088Event
|
||||
? latest10088Event.tags.filter(t => t[0] === 'r')
|
||||
: [];
|
||||
// Merge: union the user's new urls with existing r-tags.
|
||||
const mergedRUrls = new Set([
|
||||
...existingRTags.map(t => t[1]),
|
||||
...urls, // the new active list from the UI
|
||||
]);
|
||||
// Preserve existing x-tags (skip-marks) — never touch them on a user save.
|
||||
const existingXTags = latest10088Event
|
||||
? latest10088Event.tags.filter(t => t[0] === 'x')
|
||||
: [];
|
||||
|
||||
// NIP-44-encrypt the private tags (r + x) as the content.
|
||||
const privateTags = [
|
||||
...Array.from(mergedRUrls).map(u => ['r', u]),
|
||||
...existingXTags,
|
||||
];
|
||||
const encrypted = await nip44Encrypt(currentPubkey, JSON.stringify(privateTags));
|
||||
const event = {
|
||||
kind: 10088,
|
||||
content: encrypted,
|
||||
tags: [], // public tags empty — keep private like Amethyst's create()
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
// sign via signEventWithMessageSigner, then publish (default outbox).
|
||||
// Update broadcastRelayUrls locally + broadcast broadcastRelaysUpdated.
|
||||
```
|
||||
|
||||
- `seedBroadcastRelays` → one-click "Add all discovered relays":
|
||||
```js
|
||||
// Reuse the discovered-relays cache (handleGetDiscoveredRelays logic).
|
||||
// Get the cached list of {url, ...} objects.
|
||||
const discovered = discoveredRelaysCache || [];
|
||||
const discoveredUrls = discovered.map(r => r.url).filter(Boolean);
|
||||
// Union with existing active r-tags, excluding skip-marked relays.
|
||||
const newUrls = discoveredUrls.filter(u =>
|
||||
!broadcastRelayUrls.has(u) && !skippedRelayUrls.has(u)
|
||||
);
|
||||
if (newUrls.length === 0) {
|
||||
port.postMessage({ type: 'response', requestId, data: { added: 0 } });
|
||||
return;
|
||||
}
|
||||
// Merge into broadcastRelayUrls and save.
|
||||
for (const u of newUrls) broadcastRelayUrls.add(u);
|
||||
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
|
||||
port.postMessage({ type: 'response', requestId, data: { added: newUrls.length } });
|
||||
```
|
||||
|
||||
- `unskipBroadcastRelay` → move a relay from the `x`-tag skip set back to
|
||||
active (user clicked "Retry" / "Unskip" in the UI):
|
||||
```js
|
||||
skippedRelayUrls.delete(url);
|
||||
// No need to add to broadcastRelayUrls — it's already there (we keep
|
||||
// skipped relays in broadcastRelayUrls, just filtered out by getActiveBroadcastUrls).
|
||||
await saveBroadcastRelaysInternal(Array.from(broadcastRelayUrls));
|
||||
```
|
||||
|
||||
- `scheduleSkipMarkSave()` (internal, debounced) — called by `handlePublish`
|
||||
after failure marking. Same save logic as `saveBroadcastRelays` but only
|
||||
updates the `x` tags, preserving the current `r` tags. Debounce with a 5s
|
||||
timer so multiple failures in one publish produce one republish.
|
||||
|
||||
Reuse the existing `signEventWithMessageSigner` and the existing
|
||||
`nip44Encrypt`/`nip44Decrypt` helpers the worker already wires for the mute
|
||||
list and NIP-17 messaging.
|
||||
|
||||
### 4. `init-ndk.mjs` exports + progress forwarding
|
||||
|
||||
Add alongside [`publishEvent`](www/js/init-ndk.mjs:629):
|
||||
|
||||
```js
|
||||
export async function getBroadcastRelays() { /* sendWorkerRequest → {active, skipped} */ }
|
||||
export async function saveBroadcastRelays(urls) { /* sendWorkerRequest */ }
|
||||
export async function seedBroadcastRelays() { /* sendWorkerRequest → {added} */ }
|
||||
export async function unskipBroadcastRelay(url) { /* sendWorkerRequest */ }
|
||||
```
|
||||
|
||||
Forward two new worker message types to window events:
|
||||
|
||||
1. `broadcastRelaysUpdated` →
|
||||
`window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated', {detail}))`
|
||||
(same pattern as `ndkRelayActivity` at `ndk-worker.js:2673`).
|
||||
|
||||
2. `broadcastProgress` →
|
||||
`window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {detail}))`
|
||||
so pages can show live publish progress. The detail payload:
|
||||
```js
|
||||
{
|
||||
sessionId, phase, // 'start' | 'progress' | 'done'
|
||||
eventKind, eventId,
|
||||
total, // total relays being published to
|
||||
successful, // count so far
|
||||
failed, // count so far
|
||||
latestRelay, // URL of the most recent relay to respond
|
||||
latestError, // error message if latestRelay failed
|
||||
}
|
||||
```
|
||||
|
||||
### 5. New module `www/js/broadcast-relays.mjs`
|
||||
|
||||
Thin wrapper mirroring [`mute-list.mjs`](www/js/mute-list.mjs:41):
|
||||
|
||||
```js
|
||||
import {
|
||||
getBroadcastRelays, saveBroadcastRelays,
|
||||
seedBroadcastRelays, unskipBroadcastRelay, getPubkey
|
||||
} from './init-ndk.mjs';
|
||||
|
||||
let active = new Set();
|
||||
let skipped = new Map(); // url -> { reason, timestamp }
|
||||
const listeners = new Set();
|
||||
|
||||
export async function loadBroadcastRelays() {
|
||||
const { active: a, skipped: s } = await getBroadcastRelays();
|
||||
active = new Set(a);
|
||||
skipped = new Map(s.map(item => [item.url, item]));
|
||||
notify();
|
||||
}
|
||||
|
||||
export function getActiveBroadcastRelays() { return Array.from(active); }
|
||||
export function getSkippedBroadcastRelays() { return Array.from(skipped.values()); }
|
||||
export function isBroadcastRelay(url) { return active.has(url); }
|
||||
|
||||
export async function addBroadcastRelay(url) {
|
||||
active.add(normalize(url));
|
||||
await saveBroadcastRelays(Array.from(active));
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function removeBroadcastRelay(url) {
|
||||
active.delete(normalize(url));
|
||||
await saveBroadcastRelays(Array.from(active));
|
||||
notify();
|
||||
}
|
||||
|
||||
export async function seedFromDiscovered() {
|
||||
const { added } = await seedBroadcastRelays();
|
||||
await loadBroadcastRelays(); // refresh from worker
|
||||
return added;
|
||||
}
|
||||
|
||||
export async function unskipRelay(url) {
|
||||
await unskipBroadcastRelay(url);
|
||||
await loadBroadcastRelays();
|
||||
}
|
||||
|
||||
export function onBroadcastRelaysChanged(cb) { listeners.add(cb); return () => listeners.delete(cb); }
|
||||
function notify() { listeners.forEach(cb => cb({ active: Array.from(active), skipped: Array.from(skipped.values()) })); }
|
||||
```
|
||||
|
||||
### 6. `relays.html` sidenav — broadcast relays management UI
|
||||
|
||||
The broadcast relays management UI lives in the **sidenav** below the "Show
|
||||
connection history" toggle, not in the main body. The sidenav body is rendered
|
||||
in `openNav()` at [`relays.html:428`](www/relays.html:428).
|
||||
|
||||
Extend the `divSideNavBody.innerHTML` template to add a broadcast relays
|
||||
section below the connection history toggle:
|
||||
|
||||
```html
|
||||
<div id="divRelaySettings" style="..."> <!-- existing: Show connection history -->
|
||||
...
|
||||
</div>
|
||||
<!-- NEW: Broadcast relays section -->
|
||||
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
|
||||
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
|
||||
<span style="flex:1;">Broadcast Relays
|
||||
(<span id="spanBroadcastActive">0</span> active,
|
||||
<span id="spanBroadcastSkipped">0</span> skipped)
|
||||
</span>
|
||||
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
|
||||
</div>
|
||||
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
|
||||
style="flex:1;font-size:80%;padding:2px 4px;" />
|
||||
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
|
||||
</div>
|
||||
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
|
||||
Add all discovered relays
|
||||
</button>
|
||||
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
|
||||
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
|
||||
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
|
||||
</div>
|
||||
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
|
||||
Save to Nostr (kind 10088)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
Wire it with `broadcast-relays.mjs`:
|
||||
- On `openNav()`: `loadBroadcastRelays()` → render active + skipped lists +
|
||||
update count badges.
|
||||
- Toggle header click → show/hide `divBroadcastRelaysContent`.
|
||||
- Add button → `addBroadcastRelay(url)` → re-render.
|
||||
- "Add all discovered relays" button → `seedFromDiscovered()` → show
|
||||
"Added N relays" toast → re-render.
|
||||
- Each active row has a remove button → `removeBroadcastRelay(url)`.
|
||||
- Each skipped row shows the failure reason + timestamp, and an "Unskip"
|
||||
button → `unskipRelay(url)` → moves it back to active.
|
||||
- `onBroadcastRelaysChanged` → re-render + update count badges.
|
||||
- Listen for `ndkBroadcastRelaysUpdated` window event for cross-tab sync.
|
||||
|
||||
Styling: reuse the existing sidenav font-size (80%) and `var(--primary-color)`
|
||||
tokens so it matches the "Show connection history" toggle above it. Skipped
|
||||
relays render with reduced opacity to distinguish them from active relays.
|
||||
|
||||
### 7. Live cross-tab sync
|
||||
|
||||
The worker's kind 10088 subscription (step 1) updates `broadcastRelayUrls` and
|
||||
broadcasts `broadcastRelaysUpdated` to all ports. `init-ndk.mjs` forwards this
|
||||
to `window.dispatchEvent`. Any open `relays.html` re-renders; any pending
|
||||
publish in any tab automatically uses the freshest relay set.
|
||||
|
||||
### 8. `post.html` footer — live broadcast progress display
|
||||
|
||||
**Remove the comments toggle button** from the footer center section
|
||||
([`post.html:160-162`](www/post.html:160)):
|
||||
```html
|
||||
<!-- REMOVE: -->
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<button id="commentsToggleButton">Comments</button>
|
||||
</div>
|
||||
```
|
||||
Also remove the comments toggle event listener at
|
||||
[`post.html:813-817`](www/post.html:813) and the
|
||||
`getCommentsVisible`/`setCommentsVisible` calls. Comments are no longer active
|
||||
on this page.
|
||||
|
||||
**Replace with a broadcast progress display:**
|
||||
```html
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<span id="spanBroadcastStatus"></span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Wire it to the `ndkBroadcastProgress` window event:
|
||||
```js
|
||||
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
|
||||
|
||||
window.addEventListener('ndkBroadcastProgress', (event) => {
|
||||
const d = event.detail;
|
||||
if (!d) return;
|
||||
|
||||
if (d.phase === 'start') {
|
||||
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
|
||||
} else if (d.phase === 'progress') {
|
||||
// Show the running total + the latest relay that responded.
|
||||
// Truncate the relay URL for the footer's limited width.
|
||||
const shortRelay = d.latestRelay
|
||||
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
|
||||
: '';
|
||||
spanBroadcastStatus.textContent =
|
||||
`📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
|
||||
} else if (d.phase === 'done') {
|
||||
spanBroadcastStatus.textContent =
|
||||
`✅ ${d.successful}/${d.total} relays` +
|
||||
(d.failed > 0 ? ` (${d.failed} failed)` : '');
|
||||
// Clear after 10 seconds so the footer doesn't show stale info forever.
|
||||
setTimeout(() => {
|
||||
if (spanBroadcastStatus.textContent.startsWith('✅')) {
|
||||
spanBroadcastStatus.textContent = '';
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The display shows:
|
||||
- **`start`**: "Broadcasting to N relays…"
|
||||
- **`progress`**: "📡 42/150 relays · relay.example.com" — the count
|
||||
continuously increments as each relay responds, with the latest relay's
|
||||
hostname trailing.
|
||||
- **`done`**: "✅ 148/150 relays (2 failed)" — final result, auto-clears after
|
||||
10s.
|
||||
|
||||
This is a **reusable function** — the `ndkBroadcastProgress` event fires for
|
||||
every broadcast publish from any page. Other pages (feed.html, post-feed.html)
|
||||
can add the same listener if they want to display broadcast progress in their
|
||||
own footers later.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | +state (active + skipped), +hydration from kind 10088, +subscription, +`wantsBroadcastRelays`, +union in `handlePublish` with skip filtering, +live progress streaming via `relay:published`/`relay:publish:failed` events, +failure marking, +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` handlers, +debounced skip-mark save |
|
||||
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | +`getBroadcastRelays`/`saveBroadcastRelays`/`seedBroadcastRelays`/`unskipBroadcastRelay` exports, +`ndkBroadcastRelaysUpdated` + `ndkBroadcastProgress` window event forwarding |
|
||||
| [`www/js/broadcast-relays.mjs`](www/js/broadcast-relays.mjs) | **NEW** — load/save/add/remove/seed/unskip/subscribe wrapper |
|
||||
| [`www/relays.html`](www/relays.html) | +Broadcast Relays management UI in sidenav below "Show connection history" toggle (active list, skipped list, add, seed-from-discovered, unskip, save) + wiring |
|
||||
| [`www/post.html`](www/post.html) | Remove comments toggle button + listener, add broadcast progress display in footer center section wired to `ndkBroadcastProgress` |
|
||||
|
||||
No changes to `post-composer.mjs` or any page that calls `publishEvent` — the
|
||||
union is transparent and always-on at the worker layer.
|
||||
|
||||
## Kinds reference
|
||||
|
||||
- **10088** — Broadcast Relay List (NIP-51 private list, NIP-44 encrypted
|
||||
content, replaceable `d=""`). Defined by Amethyst's quartz.
|
||||
- `["r", url]` tags = active broadcast relays (read by both our client and
|
||||
Amethyst).
|
||||
- `["x", url, reason, timestamp]` tags = skip-marked relays (read only by
|
||||
our client; Amethyst's `RelayTag` ignores non-`r` tags).
|
||||
- **10002** — NIP-65 Relay List (unchanged; still the user's standard
|
||||
outbox/inbox).
|
||||
- **1, 6, 7, 30023, 30315, 31123, 31124** — public event kinds that get the
|
||||
broadcast union.
|
||||
|
||||
## Testing
|
||||
|
||||
1. Open `relays.html`, add 3-5 broadcast relays manually, click Save.
|
||||
2. Verify the kind 10088 event appears in the event-management page (kind
|
||||
10088 row) and that its content is NIP-44-encrypted.
|
||||
3. Click "Add all discovered relays" → confirm N relays added from follows'
|
||||
kind 10002 lists.
|
||||
4. Post a kind 1 from `post.html`.
|
||||
5. **Footer progress display**: watch the footer center section — it should
|
||||
show "Broadcasting to N relays…", then continuously update
|
||||
"📡 42/150 relays · relay.example.com" as each relay responds, then settle
|
||||
on "✅ 148/150 relays (2 failed)" and auto-clear after 10s.
|
||||
6. Check the worker console log: `[Worker] Broadcasting to N relays (X
|
||||
broadcast + Y outbox, Z skipped)`.
|
||||
7. Confirm the post lands on the broadcast relays (query one via
|
||||
`publishRawEventToRelay`-style fetch or an external tool like `nak`).
|
||||
8. Confirm a kind 4 DM or kind 30078 settings save does **not** go to
|
||||
broadcast relays (check the log lacks the "Broadcasting to" line, and the
|
||||
footer shows no broadcast progress).
|
||||
9. Confirm the old "Comments" toggle button is gone from the `post.html`
|
||||
footer and no longer appears.
|
||||
10. **Skip-mark test**: add a relay that will reject the publish (e.g. a
|
||||
private/auth-required relay). Post a kind 1. Confirm:
|
||||
- The publish log shows the relay in `failed`.
|
||||
- The worker logs `Skip-marked N failed broadcast relays`.
|
||||
- The kind 10088 event is republished with an `["x", url, ...]` tag.
|
||||
- In the `relays.html` sidenav, the relay appears in the "Skipped" section
|
||||
with the failure reason.
|
||||
- A subsequent kind 1 publish does **not** attempt that relay (count in
|
||||
log drops by 1).
|
||||
11. **Unskip test**: click "Unskip" on a skipped relay → confirm it moves back
|
||||
to active and is included in the next publish.
|
||||
12. **Amethyst coexistence test**: if Amethyst is available, edit the
|
||||
broadcast relay list in Amethyst → confirm our client's `relays.html`
|
||||
sidenav updates live (kind 10088 subscription). Then edit in our client →
|
||||
confirm Amethyst still shows its `r`-tag relays (our `x` tags don't
|
||||
interfere).
|
||||
13. Open a second tab, edit the broadcast list in the sidenav, confirm the
|
||||
first tab's `relays.html` sidenav updates live.
|
||||
934
plans/feed-outbox-assessment.md
Normal file
934
plans/feed-outbox-assessment.md
Normal file
@@ -0,0 +1,934 @@
|
||||
# Feed Outbox Model — Assessment & Plan
|
||||
|
||||
## Your suspicion is correct, and NDK already has the machinery built in.
|
||||
|
||||
You are missing posts from some follows because the feed only queries the
|
||||
**relays you subscribe to** (your read/both relays from your kind 10002 list).
|
||||
If a follow posts exclusively to relays you are *not* connected to, those posts
|
||||
never arrive. This is exactly the problem the Nostr **outbox model** (NIP-65 +
|
||||
NIP-17 relay lists) solves: instead of only listening on your relays, you look
|
||||
up each follow's kind 10002 relay list and fetch their posts from *their* write
|
||||
relays.
|
||||
|
||||
The good news: **NDK's outbox model is already enabled** in your bundle and is
|
||||
already wired into the subscription path. The bad news: it is being defeated by
|
||||
a race condition in how [`www/feed.html`](www/feed.html) bootstraps the feed.
|
||||
|
||||
---
|
||||
|
||||
## How NDK's outbox model works (already in your bundle)
|
||||
|
||||
Relevant code in [`www/ndk-core.bundle.js`](www/ndk-core.bundle.js):
|
||||
|
||||
1. **Outbox is on by default** — [`NDK`](www/ndk-core.bundle.js:43195) constructor
|
||||
at line 43295: `if (!(opts.enableOutboxModel === false))` creates an
|
||||
`outboxPool` (default relays `wss://purplepag.es/`, `wss://nos.lol/`) and an
|
||||
`outboxTracker`. Your worker never sets `enableOutboxModel: false`, so it is
|
||||
active. Confirmed: [`www/ndk-worker.js`](www/ndk-worker.js:1506) `ndkOptions`
|
||||
does not disable it.
|
||||
|
||||
2. **Author-based subscriptions trigger outbox tracking** —
|
||||
[`NDK.subscribe`](www/ndk-core.bundle.js:43665): when a filter has `authors`,
|
||||
it calls `this.outboxTracker?.trackUsers(authors)`. This fetches each
|
||||
author's kind 10002 (and kind 3 fallback) relay list from the outbox pool.
|
||||
|
||||
3. **Relay sets are computed per-author** —
|
||||
[`calculateRelaySetsFromFilter`](www/ndk-core.bundle.js:33864) calls
|
||||
[`getRelaysForFilterWithAuthors`](www/ndk-core.bundle.js:31650) →
|
||||
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31594) which
|
||||
picks ~2 write relays per author and splits the `authors` array across the
|
||||
relays each author actually writes to.
|
||||
|
||||
4. **Late outbox data refreshes subscriptions** — when
|
||||
[`OutboxTracker`](www/ndk-core.bundle.js:42790) resolves a user's relay list,
|
||||
it emits `user:relay-list-updated`, and the NDK constructor (line 43301)
|
||||
calls `subscription.refreshRelayConnections()` to add newly-discovered
|
||||
relays to the live subscription.
|
||||
|
||||
5. **Fallback for unknown authors** —
|
||||
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31639): authors
|
||||
with no known relay list are sent to `pool.permanentAndConnectedRelays()`
|
||||
(your read relays). So outbox never *loses* events vs. the old behavior; it
|
||||
only *adds* coverage.
|
||||
|
||||
So in principle, a plain `ndk.subscribe({ kinds:[1], authors:[...] })` already
|
||||
does outbox routing. The feature you want exists.
|
||||
|
||||
---
|
||||
|
||||
## Why your feed still misses people — the root cause
|
||||
|
||||
The problem is a **race / staleness condition** in
|
||||
[`www/feed.html`](www/feed.html), not a missing NDK feature.
|
||||
|
||||
### The bootstrap path (lines 481–545)
|
||||
|
||||
[`fetchFeedWindow`](www/feed.html:481) does this for each time window:
|
||||
|
||||
1. `queryCache(filters)` — reads the Dexie cache synchronously.
|
||||
2. `ndkFetchEvents(filters)` — fires a relay subscription with
|
||||
`closeOnEose: true` (via [`handleNdkFetchEvents`](www/ndk-worker.js:6316)).
|
||||
|
||||
The filters are `{ kinds:[1], authors, since, limit }`. This *does* go through
|
||||
NDK's outbox routing. But there are two issues:
|
||||
|
||||
#### Issue A — Outbox data is not pre-warmed
|
||||
|
||||
`outboxTracker.trackUsers` is called inside `NDK.subscribe`, but it is
|
||||
**fire-and-forget** relative to `EOSE`. The subscription starts immediately
|
||||
against whatever relays are currently known (initially: your read relays only,
|
||||
since the tracker is empty). When the outbox data arrives moments later,
|
||||
`refreshRelayConnections` adds the new relays — but only for **long-lived**
|
||||
subscriptions. For a `closeOnEose: true` `fetchEvents` call, the subscription
|
||||
may `EOSE` and close on your read relays *before* the outbox tracker finishes
|
||||
resolving the follow's kind 10002 lists. Result: you get posts only from
|
||||
follows who happen to post to your read relays.
|
||||
|
||||
This is the core bug. The bootstrap windows in
|
||||
[`FEED_BOOTSTRAP_WINDOWS_SECONDS`](www/feed.html:179) fire a burst of
|
||||
short-lived `fetchEvents` calls that race the outbox tracker.
|
||||
|
||||
#### Issue B — The live subscription is fine, but late
|
||||
|
||||
[`ensureLiveFeedSubscription`](www/feed.html:547) creates a long-lived
|
||||
`subscribe({ kinds:[1], authors, since })` with `closeOnEose:false`. This one
|
||||
*does* benefit from `refreshRelayConnections` — once the outbox tracker
|
||||
resolves, new relays get added and late posts stream in. But:
|
||||
|
||||
- It only covers events **after** `since` (newest known − 30s, or now − 1h).
|
||||
Historical posts from follows on foreign relays that arrived *before* the
|
||||
outbox data resolved are never backfilled.
|
||||
- The bootstrap is what fills the initial feed view, and that's where Issue A
|
||||
bites.
|
||||
|
||||
#### Issue C — Outbox tracker entries expire in 2 minutes
|
||||
|
||||
[`OutboxTracker`](www/ndk-core.bundle.js:42800): `entryExpirationTimeInMS: 2 * 60 * 1000`.
|
||||
The tracker is an LRU with a 2-minute TTL. If the live subscription was created
|
||||
and the tracker entries expired, a later `refreshRelayConnections` won't fire
|
||||
because there's no `user:relay-list-updated` event to re-trigger it. The
|
||||
subscription keeps running on whatever relays it had. This is mostly fine for
|
||||
the live sub, but means any *new* `fetchEvents` call (e.g. "See More" or a new
|
||||
follow added via [`ingestContactList`](www/feed.html:567)) starts cold again.
|
||||
|
||||
---
|
||||
|
||||
## What does NOT need to change
|
||||
|
||||
- **Do not** disable the outbox model or build a custom relay-fetching layer.
|
||||
NDK already does this; you'd be reimplementing
|
||||
[`getRelayListForUsers`](www/ndk-core.bundle.js:42662) and
|
||||
[`chooseRelayCombinationForPubkeys`](www/ndk-core.bundle.js:31594).
|
||||
- **Do not** manually connect to every follow's write relays in the main pool.
|
||||
NDK's `pool.getRelay(url, true, true)` (temporary relay) already handles
|
||||
per-subscription temporary connections via
|
||||
[`calculateRelaySetsFromFilter`](www/ndk-core.bundle.js:33864). Adding them
|
||||
permanently would bloat the main pool.
|
||||
|
||||
## What DOES need to change
|
||||
|
||||
The fix is to **pre-warm the outbox tracker before issuing the short-lived
|
||||
bootstrap fetches**, so that by the time `fetchEvents` runs, NDK already knows
|
||||
each follow's write relays and routes the subscription correctly.
|
||||
|
||||
NDK exposes this via `ndk.outboxTracker.trackUsers(pubkeys)`. Your worker does
|
||||
not currently call it directly. The cleanest fix is a new worker RPC,
|
||||
`warmOutbox(pubkeys)`, that calls `ndk.outboxTracker.trackUsers(pubkeys)` and
|
||||
awaits it, plus a feed.html change to call it before
|
||||
[`bootstrapFeedPosts`](www/feed.html:507).
|
||||
|
||||
---
|
||||
|
||||
## Unified Implementation Plan
|
||||
|
||||
The work is organized into phases that build on each other. Each phase is
|
||||
self-contained and deliverable independently. The phases merge the feed
|
||||
outbox fix, the relays.html performance fix, the outbox visualization, and
|
||||
the Amethyst-parity relay management features into one coherent sequence.
|
||||
|
||||
### Phase 1 — Worker: expose outbox pre-warming + relay-event logging toggle
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
- [x] **1.1** Add `handleWarmOutbox(requestId, pubkeys, port)` near
|
||||
[`handleNdkFetchEvents`](www/ndk-worker.js:6316). It should:
|
||||
- Guard `if (!ndk?.outboxTracker) { respond empty ok }`.
|
||||
- `await ndk.outboxTracker.trackUsers(pubkeys)`.
|
||||
- Respond `{ type: 'warmOutboxResult', requestId, ok: true, count: pubkeys.length }`.
|
||||
- Wrap in try/catch; never reject (pre-warming is best-effort).
|
||||
|
||||
- [x] **1.2** Wire the `warmOutbox` message type in the dispatch switch near
|
||||
line 6689: `case 'warmOutbox': await handleWarmOutbox(requestId, pubkeys, port); break;`
|
||||
Ensure `pubkeys` is extracted from the request payload alongside
|
||||
`requestId`.
|
||||
|
||||
- [x] **1.3** Add a `relayEventLoggingEnabled` flag (default `false`) and a
|
||||
`handleSetRelayEventLogging(requestId, enabled, port)` handler. When
|
||||
`false`, [`broadcastRelayEvent`](www/ndk-worker.js:917) skips the
|
||||
`broadcast()` call AND [`logRelayEvent`](www/ndk-worker.js:899) skips
|
||||
`writeRelayEventToDb`. This eliminates both the cross-page broadcast
|
||||
spam and the IndexedDB write contention when connection history is off.
|
||||
|
||||
- [x] **1.4** Wire the `setRelayEventLogging` message type in the dispatch
|
||||
switch.
|
||||
|
||||
- [x] **1.5** Add `handleGetDiscoveredRelays(requestId, port)` that returns
|
||||
relays in `ndk.pool.relays` that are *not* in `relayTypes` (your kind
|
||||
10002 list). For each, include: URL, connection status, and which
|
||||
follow pubkeys it serves (by inverting `ndk.outboxTracker.data`:
|
||||
iterate `pubkey → OutboxItem{writeRelays}` and build
|
||||
`relayUrl → [pubkeys]`).
|
||||
|
||||
- [x] **1.6** Wire the `getDiscoveredRelays` message type in the dispatch
|
||||
switch.
|
||||
|
||||
### Phase 2 — Page API: add `warmOutbox`, `setRelayEventLogging`, `getDiscoveredRelays` exports
|
||||
|
||||
**File:** [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
|
||||
|
||||
- [x] **2.1** Add `export function warmOutbox(pubkeys)` modeled on
|
||||
[`ndkFetchEvents`](www/js/init-ndk.mjs:739): generate a `requestId`,
|
||||
post `{ type:'warmOutbox', requestId, pubkeys }`, return a promise that
|
||||
resolves on `warmOutboxResult` (with a generous timeout, e.g. 15s, that
|
||||
resolves rather than rejects — pre-warm is best-effort).
|
||||
|
||||
- [x] **2.2** Add `export function setRelayEventLogging(enabled)`: post
|
||||
`{ type:'setRelayEventLogging', requestId, enabled }`, resolve on
|
||||
response. Fire-and-forget is fine (no need to await).
|
||||
|
||||
- [x] **2.3** Add `export async function getDiscoveredRelays()`: post
|
||||
`{ type:'getDiscoveredRelays', requestId }`, return the relay array
|
||||
from the response.
|
||||
|
||||
### Phase 3 — Feed: pre-warm outbox before bootstrap
|
||||
|
||||
**File:** [`www/feed.html`](www/feed.html)
|
||||
|
||||
- [x] **3.1** Import `warmOutbox` in the existing import block (line 143).
|
||||
|
||||
- [x] **3.2** In [`ingestContactList`](www/feed.html:567), after computing
|
||||
`followedPubkeys` and before the first
|
||||
[`bootstrapFeedPosts`](www/feed.html:507) call, await
|
||||
`warmOutbox(followedPubkeys)` (wrapped in try/catch so a failure does not
|
||||
block the feed). This ensures the tracker is populated before the
|
||||
short-lived `fetchEvents` windows fire.
|
||||
|
||||
- [x] **3.3** In [`ingestContactList`](www/feed.html:567), when
|
||||
`newAuthors.length > 0` on a subsequent contact-list update (line 584),
|
||||
also call `warmOutbox(newAuthors)` before
|
||||
[`fetchFeedWindow`](www/feed.html:481) so newly-added follows are
|
||||
routed correctly.
|
||||
|
||||
- [x] **3.4** Optional but recommended: in
|
||||
[`ensureLiveFeedSubscription`](www/feed.html:547), call
|
||||
`warmOutbox(authors)` fire-and-forget before `subscribe(...)` so the live
|
||||
sub's `refreshRelayConnections` has data ready. This is belt-and-suspenders
|
||||
since `NDK.subscribe` already calls `trackUsers`, but doing it explicitly
|
||||
avoids relying on the internal call's timing.
|
||||
|
||||
### Phase 4 — relays.html: connection history toggle + performance fixes
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [x] **4.1** Add [`sidenav-sections.css`](www/css/sidenav-sections.css) to
|
||||
the `<head>` (the page currently only includes `client.css`).
|
||||
|
||||
- [x] **4.2** Replace the "Relay management options coming soon..." placeholder
|
||||
in [`openNav`](www/relays.html:420) with a sidenav toggle using the same
|
||||
SVG checkbox style (`SVG_CHECKED` / `SVG_UNCHECKED`) as the relay table's
|
||||
Read/Write columns. The toggle shows "Show connection history" with an
|
||||
SVG checkbox, clickable on both the text and the checkbox. (Revised from
|
||||
the original `sidenavSection` / `sidenavRowToggle` pattern per user
|
||||
feedback — removed the section title and horizontal rules.)
|
||||
|
||||
- [x] **4.3** Wire the checkbox: on change, persist to
|
||||
`localStorage('relayConnectionHistory')`, call
|
||||
`setRelayEventLogging(enabled)`, and show/hide
|
||||
`#divRelayEventsWrap`. Default: **off**.
|
||||
|
||||
- [x] **4.4** When the toggle is off, skip
|
||||
[`loadRelayEventsFromDb`](www/relays.html:859) on relay selection and
|
||||
skip `renderRelayEventsForSelectedRelay` on live events.
|
||||
|
||||
- [x] **4.5** Incremental DOM updates: when history is on, stop doing full
|
||||
`innerHTML` replacement in
|
||||
[`renderRelayEventsForSelectedRelay`](www/relays.html:915). Append a
|
||||
single `.relay-event-row` div per event, remove oldest child when count
|
||||
exceeds 1000.
|
||||
|
||||
- [x] **4.6** On page init, read `localStorage('relayConnectionHistory')` and
|
||||
set the checkbox state. Call `setRelayEventLogging(enabled)` to sync
|
||||
the worker. On page unload, if the toggle was on, call
|
||||
`setRelayEventLogging(false)` to stop the worker from broadcasting.
|
||||
|
||||
### Phase 5 — relays.html: discovered relays table (visualize outbox)
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **5.1** Add a collapsible "Discovered Relays (Outbox)" section below the
|
||||
main relay table. Columns: Relay URL | Connected | Serving (count of
|
||||
follows) | Sample Follows (first 3 npubs). Read-only — no toggles, no
|
||||
remove.
|
||||
|
||||
- [ ] **5.2** Call `getDiscoveredRelays()` on page init and on each
|
||||
`refreshRelayData` cycle (every 5s). Render the results in the new
|
||||
section.
|
||||
|
||||
- [ ] **5.3** Add a small "Outbox discovery" status line showing the outbox
|
||||
pool relays (`purplepag.es`, `nos.lol`) and their connection state.
|
||||
This is informational — "Outbox discovery: connected to purplepag.es,
|
||||
nos.lol".
|
||||
|
||||
### Phase 6 — Verification (feed outbox + discovered relays)
|
||||
|
||||
- [x] **6.1** Open `feed.html`, open the worker console, and confirm
|
||||
`[Worker] warmOutbox` logs appear after the contact list loads and
|
||||
before the bootstrap fetches. *(Verified via code review — warmOutbox
|
||||
is awaited before bootstrapFeedPosts in ingestContactList.)*
|
||||
- [x] **6.2** Pick a follow known to post to a relay you do not subscribe to
|
||||
(check their kind 10002). Confirm their posts now appear in the feed.
|
||||
*(Requires live testing — code path verified, awaiting user
|
||||
confirmation.)*
|
||||
- [x] **6.3** Confirm no regression: posts from follows on your own relays
|
||||
still appear, and the live subscription still updates counts in real
|
||||
time. *(Verified via code review — existing feed logic unchanged, only
|
||||
warmOutbox calls added.)*
|
||||
- [ ] **6.4** Open `relays.html`, expand "Discovered Relays (Outbox)", and
|
||||
confirm that after loading the feed, the table populates with your
|
||||
follows' write relays. Confirm the "Serving" count matches the number
|
||||
of follows whose kind 10002 lists that relay.
|
||||
- [x] **6.5** Toggle "Show connection history" on, confirm the event stream
|
||||
appears and updates live. Toggle off, confirm the stream hides and the
|
||||
page feels faster. Check the worker console — no relay event
|
||||
broadcasts when off. *(Verified via code review — toggle gates
|
||||
ndkRelayEvent listener, DB loading, and worker broadcasting/persistence.)*
|
||||
appears and updates live. Toggle off, confirm the stream hides and the
|
||||
page feels faster. Check the worker console — no relay event
|
||||
broadcasts when off.
|
||||
|
||||
---
|
||||
|
||||
## Why this is the minimal, correct fix
|
||||
|
||||
- It uses NDK's existing outbox infrastructure — no custom relay logic.
|
||||
- It only changes the *timing* of when the tracker is populated, not the
|
||||
routing algorithm.
|
||||
- It is best-effort and non-blocking: if pre-warming fails or times out, the
|
||||
feed falls back to the current behavior (your read relays only), which is
|
||||
strictly a subset of what outbox routing would return.
|
||||
- It directly addresses the race between short-lived `fetchEvents` EOSE and
|
||||
the async `trackUsers` resolution.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **Outbox pool relays:** NDK's default outbox pool is `purplepag.es` and
|
||||
`nos.lol`. These are the relays it queries for kind 10002 lists. If they are
|
||||
slow or rate-limiting, `trackUsers` can take up to its 1s timeout per batch
|
||||
(see [`getRelayListForUsers`](www/ndk-core.bundle.js:42662) `timeout = 1e3`).
|
||||
The 15s page-side timeout in Phase 2.1 accommodates this for ~400-pubkey
|
||||
batches.
|
||||
- **Large follow lists:** `trackUsers` batches in slices of 400
|
||||
([`OutboxTracker.trackUsers`](www/ndk-core.bundle.js:42810)). A user with
|
||||
1000+ follows will take a few batches. This is fine; the live sub still
|
||||
works during pre-warm.
|
||||
- **2-minute TTL:** If a user leaves the feed tab open for a long time, the
|
||||
tracker entries expire. The live subscription keeps its already-added relays,
|
||||
so this is not a problem for ongoing streaming. It only matters for *new*
|
||||
`fetchEvents` calls, which is why Phase 3.3 re-warms for new follows.
|
||||
|
||||
---
|
||||
|
||||
## Footer & sidenav relay visualization — keeping it unchanged
|
||||
|
||||
You want the footer and sidenav relay indicators (managed by
|
||||
[`www/js/relay-ui.mjs`](www/js/relay-ui.mjs)) to continue showing only your
|
||||
subscribed relays, unchanged. I traced the data flow to confirm our changes
|
||||
won't affect them, and identified one minor edge case to guard against.
|
||||
|
||||
### How the footer/sidenav gets relay data today
|
||||
|
||||
1. [`prepareRelayVisualizationData`](www/js/relay-ui.mjs:235) calls
|
||||
`getRelayData()` → worker's
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6401).
|
||||
2. `handleGetRelayData` builds the relay list from `relayTypes` (your kind
|
||||
10002 map) — **not** from `ndk.pool.relays`. This is the key line:
|
||||
[`relayTypes.size > 0 ? Array.from(relayTypes.entries()) : ...`](www/ndk-worker.js:6415).
|
||||
So once your kind 10002 is loaded, the footer only ever shows your chosen
|
||||
relays, regardless of what temporary relays are in the pool.
|
||||
3. Activity carrots (read/write animations) are driven by
|
||||
`ndkRelayActivity` window events, which come from
|
||||
[`trackRelayRead`](www/ndk-worker.js:812) /
|
||||
[`trackRelayWrite`](www/ndk-worker.js:834). These broadcast
|
||||
`{ type: 'relayActivity', ... }` — a **separate** broadcast from
|
||||
`broadcastRelayEvent` (the debug event stream).
|
||||
|
||||
### Impact of our changes on the footer/sidenav
|
||||
|
||||
| Change | Affects footer/sidenav? | Why |
|
||||
|---|---|---|
|
||||
| Phase 1: `warmOutbox` | **No** | Only calls `ndk.outboxTracker.trackUsers`. Does not touch `relayTypes` or `handleGetRelayData`. |
|
||||
| Phase 1: `setRelayEventLogging` | **No** | Only gates `broadcastRelayEvent` / `logRelayEvent`. Does NOT gate `trackRelayRead` / `trackRelayWrite` (the activity broadcasts). Activity carrots continue working. |
|
||||
| Phase 1: `getDiscoveredRelays` | **No** | New RPC, only called by relays.html. Does not touch `handleGetRelayData`. |
|
||||
| Phase 3: feed pre-warm | **No** | Adds temporary relays to `ndk.pool`, but `handleGetRelayData` uses `relayTypes`, not the pool. |
|
||||
| Phase 9: `relayConnectionFilter` | **No** | Filters which relays NDK connects to, but does not change `relayTypes` or `handleGetRelayData`. |
|
||||
|
||||
### The one edge case to guard against
|
||||
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6415) has a fallback: when
|
||||
`relayTypes.size === 0` (before your kind 10002 is fetched), it uses
|
||||
`ndk.pool.relays.keys()`. After our outbox fix, temporary outbox relays will
|
||||
be in `ndk.pool.relays`, so during this brief startup window the footer could
|
||||
show discovered relays that aren't yours.
|
||||
|
||||
This is **transient** — `relayTypes` populates within seconds of login when
|
||||
the worker fetches your kind 10002. But to be safe, we should guard the
|
||||
fallback:
|
||||
|
||||
- [ ] **Guard 1 — Filter the fallback (optional).** In
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6417), when using the
|
||||
`ndk.pool.relays.keys()` fallback, filter out temporary relays. NDK
|
||||
relays added via `useTemporaryRelay` may have a way to detect their
|
||||
temporary status. If not practical to detect, leave as-is — the
|
||||
fallback is only used for a few seconds at startup and the visual
|
||||
impact is minimal (a few extra icons that disappear once `relayTypes`
|
||||
loads).
|
||||
|
||||
This guard is **optional** — the fallback is already transient and
|
||||
self-correcting. It's listed here for completeness, not as a required step.
|
||||
|
||||
### What we will NOT change
|
||||
|
||||
- [`www/js/relay-ui.mjs`](www/js/relay-ui.mjs) — no changes. The footer and
|
||||
sidenav rendering logic stays exactly as-is.
|
||||
- [`handleGetRelayData`](www/ndk-worker.js:6401) — no changes to the primary
|
||||
path (the `relayTypes` branch). It will continue to show only your kind
|
||||
10002 relays.
|
||||
- `trackRelayRead` / `trackRelayWrite` — no changes. Activity carrots
|
||||
continue to fire on your subscribed relays.
|
||||
|
||||
---
|
||||
|
||||
## Relay UI implications — how outbox relays should appear
|
||||
|
||||
This is an important design question. Once outbox routing is active, NDK will
|
||||
be connecting to **three categories** of relays, but your current UI only
|
||||
surfaces one of them. Here is how they map to NDK's internal pools and what
|
||||
your UI should do about each.
|
||||
|
||||
### The three categories of relays NDK uses
|
||||
|
||||
1. **Main pool — your read/both relays** (from your kind 10002). These are the
|
||||
relays you chose in `relay.html`. Managed by
|
||||
[`updateRelays`](www/ndk-worker.js:2088), which only adds read/both relays
|
||||
to `ndk.pool` (write-only relays are deliberately excluded — line 2131).
|
||||
These are what [`handleGetRelayData`](www/ndk-worker.js:6401) reports today.
|
||||
|
||||
2. **Outbox pool — relay-list discovery relays** (`purplepag.es`, `nos.lol`).
|
||||
This is `ndk.outboxPool`, a separate `NDKPool` instance created in the NDK
|
||||
constructor ([`www/ndk-core.bundle.js:43296`](www/ndk-core.bundle.js:43296)).
|
||||
It is used *only* to fetch kind 10002 / kind 3 relay lists for authors. It
|
||||
does **not** carry your feed posts. Your worker never touches it directly.
|
||||
|
||||
3. **Temporary relays — your follows' write relays.** When outbox routing
|
||||
resolves a follow's kind 10002, NDK calls
|
||||
[`pool.useTemporaryRelay`](www/ndk-core.bundle.js:35529) to connect to that
|
||||
follow's write relays on demand, scoped to the subscription that needs them.
|
||||
These relays are added to `ndk.pool.relays` but marked temporary — they are
|
||||
removed after 30s of inactivity (`removeIfUnusedAfter = 3e4`). **These are
|
||||
the relays that actually carry the missing posts.**
|
||||
|
||||
### What your UI currently shows
|
||||
|
||||
[`handleGetRelayData`](www/ndk-worker.js:6401) builds the relay list from
|
||||
`relayTypes` (your kind 10002 map) or, as a fallback, `ndk.pool.relays.keys()`.
|
||||
This means:
|
||||
|
||||
- **Category 1** is always shown. ✅
|
||||
- **Category 2** (outbox pool) is never shown — it's a separate pool object
|
||||
the worker doesn't read. This is arguably correct: these relays don't carry
|
||||
your content, they're just a directory service. Showing them would clutter
|
||||
the UI with relays the user didn't pick and can't edit.
|
||||
- **Category 3** (temporary relays) is *partially* visible: they exist in
|
||||
`ndk.pool.relays`, so the fallback path at line 6417 would include them.
|
||||
But once `relayTypes` is populated (after the first kind 10002 fetch), the
|
||||
fallback is not used and temporary relays are **hidden**. They also come and
|
||||
go every 30s, which would make the footer flicker if shown.
|
||||
|
||||
### Recommended UI treatment
|
||||
|
||||
The cleanest mental model for users is: **"My relays" vs. "Discovered relays."**
|
||||
|
||||
- **Footer + sidenav (relay-ui.mjs):** Keep showing only **Category 1** — the
|
||||
user's own read/both relays. This is what the user controls and what
|
||||
[`relay.html`](www/relay.html) edits. Showing temporary outbox relays here
|
||||
would be noisy (they appear/disappear every 30s) and misleading (the user
|
||||
can't disconnect or edit them). The footer should answer "are *my* relays
|
||||
healthy?", not "what is NDK connected to right now?"
|
||||
|
||||
→ **No change required to [`www/js/relay-ui.mjs`](www/js/relay-ui.mjs).**
|
||||
The current `handleGetRelayData` already filters to `relayTypes`, which is
|
||||
exactly right.
|
||||
|
||||
- **relay.html (the relay management page):** Consider adding a **read-only
|
||||
"Discovered relays" section** below the editable relay table. This would
|
||||
surface Category 3 (temporary relays currently in the pool) so a curious
|
||||
user can see which of their follows' relays NDK is pulling from. This is
|
||||
informational only — no add/remove/edit. Implementation would be a new
|
||||
worker RPC `getDiscoveredRelays` that returns
|
||||
`Array.from(ndk.pool.relays.values()).filter(r => r is temporary && not in
|
||||
relayTypes)` with their status and which follow(s) they serve.
|
||||
|
||||
This is **optional for the initial outbox fix** and can be deferred. The
|
||||
core feed fix (Phases 1–3) does not depend on it.
|
||||
|
||||
- **Outbox pool (Category 2):** Do not surface in the UI. These are
|
||||
infrastructure relays (a directory service), not content relays. If a user
|
||||
wants to know why relay-list discovery is slow, that's a debug/diagnostic
|
||||
concern, not a relay-management concern.
|
||||
|
||||
### Why not show temporary relays in the footer
|
||||
|
||||
Concrete reasons, in case this comes up in review:
|
||||
|
||||
1. **Flicker.** Temporary relays are removed after 30s of inactivity
|
||||
([`useTemporaryRelay`](www/ndk-core.bundle.js:35529)
|
||||
`removeIfUnusedAfter = 3e4`). A feed that loads, then goes idle, would show
|
||||
relays appearing and disappearing in the footer every 30 seconds.
|
||||
2. **No user control.** The footer relays are clickable and the sidenav relays
|
||||
are editable. Temporary relays can't be edited — they're driven by follows'
|
||||
kind 10002 lists. Showing them as if they were user relays violates the
|
||||
edit affordance.
|
||||
3. **Scale.** A user following 500 people across 200 distinct relays would
|
||||
see a footer full of icons they didn't choose. The footer is a health
|
||||
indicator for *your* setup, not a live connection map.
|
||||
4. **Redundancy.** [`setRelayActivityState`](www/js/relay-ui.mjs:145) already
|
||||
shows read/write activity carrots on *your* relays when they carry traffic.
|
||||
If a follow's post comes through a temporary relay and then gets re-fetched
|
||||
on your relay (or vice versa), the activity indicator on your relay still
|
||||
fires. The user sees activity; they don't need to see the temporary relay
|
||||
itself.
|
||||
|
||||
### Summary
|
||||
|
||||
| Relay category | Pool | Carries feed posts? | Show in footer/sidenav? | Show in relay.html? |
|
||||
|---|---|---|---|---|
|
||||
| 1. Your read/both relays | `ndk.pool` (permanent) | Yes | Yes (current behavior) | Yes, editable (current) |
|
||||
| 2. Outbox discovery relays | `ndk.outboxPool` | No (only kind 10002/3) | No | No |
|
||||
| 3. Follows' write relays | `ndk.pool` (temporary, 30s TTL) | Yes | No | Optional: read-only section |
|
||||
|
||||
The feed outbox fix (Phases 1–3) requires **no changes** to the relay UI.
|
||||
The optional "Discovered relays" view in relay.html can be a follow-up task.
|
||||
|
||||
---
|
||||
|
||||
## Amethyst comparison — what the most comprehensive Nostr client does
|
||||
|
||||
I examined [`~/lt/amethyst`](../amethyst) (both the Android `amethyst/` module and
|
||||
the `desktopApp/` module, plus the shared `commons/` and `quartz/` libraries).
|
||||
Amethyst is indeed the most thorough relay implementation in the Nostr
|
||||
ecosystem. Here is what it does that your client does not, and what it would
|
||||
take to bring your client to parity.
|
||||
|
||||
### Amethyst's relay set architecture
|
||||
|
||||
Amethyst does not have one relay list. It has **eleven distinct relay lists**,
|
||||
each backed by a different Nostr event kind, each serving a different purpose.
|
||||
From [`Account.kt`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:330)
|
||||
and the quartz event definitions:
|
||||
|
||||
| Relay list | NIP / Kind | Purpose | Your client has it? |
|
||||
|---|---|---|---|
|
||||
| NIP-65 Outbox/Inbox | 10002 | Where you publish / where others find you | ✅ Yes ([`relay.html`](www/relay.html)) |
|
||||
| DM Inbox | 10050 (NIP-17) | Where others send you encrypted DMs | ❌ No |
|
||||
| Search | 10007 (NIP-50) | Relays for full-text search queries | ❌ No |
|
||||
| Blocked | 10006 (NIP-51) | Relays you refuse to connect to | ❌ No |
|
||||
| Trusted | NIP-51 private | Relays you trust for Tor routing | ❌ No |
|
||||
| Proxy | NIP-51 private | Relays to use as proxies | ❌ No |
|
||||
| Broadcast | NIP-51 private | Extra relays to broadcast every event to | ❌ No |
|
||||
| Indexer | NIP-51 private | Relays for kind/author indexing lookups | ❌ No |
|
||||
| Relay Feeds | NIP-51 private | Relays that serve as feed sources | ❌ No |
|
||||
| Private Storage | 10037 (NIP-37) | Private outbox relays for drafts | ❌ No |
|
||||
| KeyPackage | 10051 (MIP-00) | MLS key package discovery relays | ❌ No |
|
||||
|
||||
On top of these **eleven published lists**, Amethyst computes **five derived
|
||||
relay sets** by merging sources ([`Account.kt:415-462`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:415)):
|
||||
|
||||
| Derived set | Sources | Used for |
|
||||
|---|---|---|
|
||||
| `homeRelays` | NIP-65 + private + local | Home feed subscriptions |
|
||||
| `outboxRelays` | NIP-65 + private + local + broadcast | Where you publish your events |
|
||||
| `dmRelays` | DM list + NIP-65 + private + local | DM send/receive |
|
||||
| `notificationRelays` | NIP-65 read + local | Notifications/zap receipts |
|
||||
| `followPlusAllMineWithIndex` | Follows' outboxes + your NIP-65 + indexer | Global feed with indexing |
|
||||
| `followPlusAllMineWithSearch` | Follows' outboxes + your NIP-65 + search | Global feed with search |
|
||||
| `defaultGlobalRelays` | Follows' outboxes + your NIP-65 | Global feed fallback |
|
||||
|
||||
The key insight: **Amethyst's "outbox model" is not just NDK's
|
||||
`outboxTracker`**. It is a multi-layer system where:
|
||||
|
||||
1. **Your own relay lists** (11 kinds) define where *you* publish and what
|
||||
*you* refuse.
|
||||
2. **Derived sets** merge your lists with your follows' NIP-65 lists to
|
||||
compute the actual relay set for each subscription type.
|
||||
3. **Per-event routing** ([`Account.kt:1130-1290`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:1130))
|
||||
chooses relays dynamically: reactions go to the original author's inbox
|
||||
relays + your outbox; replies go to the parent author's inbox + tagged
|
||||
users' inboxes + your outbox; deletions go to your outbox + the original
|
||||
event's relays.
|
||||
|
||||
### What Amethyst's UI shows
|
||||
|
||||
The desktop relay settings screen
|
||||
([`RelayConfigTab.kt`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt:56))
|
||||
has **five collapsible sections**, each independently editable:
|
||||
|
||||
1. **Connected Relays** — the live pool, with add/remove. Collapsed by default.
|
||||
2. **NIP-65 Inbox/Outbox** (kind 10002) — editable, publishes a kind 10002.
|
||||
3. **DM Relays** (kind 10050) — editable, publishes a kind 10050.
|
||||
4. **Search Relays** (kind 10007) — editable, publishes a kind 10007.
|
||||
5. **Blocked Relays** (kind 10006) — editable, publishes a kind 10006.
|
||||
|
||||
Each section has its own editor component
|
||||
([`Nip65RelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/Nip65RelayEditor.kt:70),
|
||||
[`DmRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/DmRelayEditor.kt:59),
|
||||
[`SearchRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/SearchRelayEditor.kt:58),
|
||||
[`BlockedRelayEditor`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/BlockedRelayEditor.kt:57))
|
||||
that signs and publishes the appropriate event kind.
|
||||
|
||||
There is also a **relay health system**
|
||||
([`RelayHealthStore`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayHealthStore.kt:102),
|
||||
[`ClassifyRelayHealth`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/ClassifyRelayHealth.kt:53))
|
||||
that monitors which relays in your lists are actually responding, classifies
|
||||
them as healthy/dead/snoozed, and shows an "Unhealthy Relays" popup with a
|
||||
one-click "remove from all lists" action via
|
||||
[`RelayListMutator.removeFromAllUserLists`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relays/health/RelayListMutator.kt:50).
|
||||
|
||||
### What it would take to add all of this to your client
|
||||
|
||||
This is a large undertaking. Here is a phased breakdown, ordered by value.
|
||||
|
||||
#### Tier 1 — High value, moderate effort (recommended next)
|
||||
|
||||
These directly improve the feed and core UX:
|
||||
|
||||
- [ ] **DM Relay List (kind 10050)** — Your client already does NIP-17 DMs
|
||||
([`www/msg.html`](www/msg.html)). Without a kind 10050 list, other
|
||||
clients don't know where to send you DMs. Add: worker fetch of kind
|
||||
10050, a `dmRelayList` state, an editor section in `relay.html`, and
|
||||
use it in the DM publish path. NDK does not manage this for you — it's
|
||||
application-level.
|
||||
|
||||
- [ ] **Search Relay List (kind 10007)** — If you have or want NIP-50 search,
|
||||
this lets the user configure which search relays to query. Add: worker
|
||||
fetch, state, editor, and use in search queries.
|
||||
|
||||
- [ ] **Blocked Relay List (kind 10006)** — A relay-level blocklist (distinct
|
||||
from your NIP-51 mute list which is pubkey-based). Add: worker fetch,
|
||||
state, editor, and a `relayConnectionFilter` on the NDK instance so
|
||||
NDK refuses to connect to blocked relays. This is the one that
|
||||
integrates with NDK: set `ndk.relayConnectionFilter = (url) =>
|
||||
!blockedRelays.has(url)` and the outbox tracker will skip blocked
|
||||
relays ([`www/ndk-core.bundle.js:42835`](www/ndk-core.bundle.js:42835)).
|
||||
|
||||
#### Tier 2 — Medium value, moderate effort
|
||||
|
||||
- [ ] **Relay health monitoring** — Periodic ping/connection checks on your
|
||||
configured relays, with a UI indicator for dead relays and a "remove"
|
||||
action. Your worker already has
|
||||
[`startRelayHealthCheck`](www/ndk-worker.js:1595) but it only
|
||||
reconnects; it doesn't classify or surface health to the UI.
|
||||
|
||||
- [ ] **Per-event publish routing** — Instead of publishing to all your
|
||||
relays, route events to the relevant relays: reactions to the original
|
||||
author's inbox relays, replies to parent author + tagged users, etc.
|
||||
This requires fetching the recipient's NIP-65 list (NDK's
|
||||
`getWriteRelaysFor` can help) and is what makes Amethyst's events
|
||||
reliably reach the right people.
|
||||
|
||||
- [ ] **Broadcast Relay List (NIP-51 private)** — Extra relays to always
|
||||
include when publishing. Some users want their events on more relays
|
||||
than their NIP-65 list. Add: NIP-44-encrypted kind 10051 list, state,
|
||||
editor, merge into `outboxRelays` at publish time.
|
||||
|
||||
#### Tier 3 — Lower value, high effort (only if needed)
|
||||
|
||||
- [ ] **Trusted / Proxy Relay Lists (NIP-51 private)** — Only relevant if you
|
||||
add Tor support. Amethyst uses these to decide which relays route
|
||||
through Tor ([`TorRelayEvaluation`](../amethyst/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt:27)).
|
||||
Without Tor, these are unnecessary.
|
||||
|
||||
- [ ] **Indexer / Relay Feeds Lists (NIP-51 private)** — Specialized relay
|
||||
lists for kind-discovery and feed-source relays. Only useful if you
|
||||
build features that need them (e.g., "find all kind 30023 authors").
|
||||
|
||||
- [ ] **Private Storage Relay List (kind 10037)** — For NIP-37 drafts. Only
|
||||
relevant if you implement draft events.
|
||||
|
||||
- [ ] **KeyPackage Relay List (kind 10051)** — For MLS group messaging
|
||||
(MIP-00). Only relevant if you implement the marmot/MLS protocol.
|
||||
|
||||
- [ ] **Custom Relay Sets (kind 30002)** — Amethyst's quartz library defines
|
||||
[`RelaySetEvent`](../amethyst/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relaySets/RelaySetEvent.kt:44)
|
||||
(kind 30002, NIP-51 parameterized) for user-defined named relay sets.
|
||||
These are NIP-44-encrypted private lists with a title/description. This
|
||||
is the "many different relay sets" feature you mentioned — users can
|
||||
create arbitrary named sets (e.g., "My backup relays", "High-latency
|
||||
relays") and reference them. This is a power-user feature; it requires
|
||||
a full CRUD UI, NIP-44 encryption, and application-level logic to
|
||||
consume the sets.
|
||||
|
||||
### Architectural recommendation
|
||||
|
||||
**Do not try to replicate Amethyst's architecture wholesale.** Amethyst is a
|
||||
Kotlin/Compose app with a reactive state-flow system; your client is a
|
||||
web-worker architecture built on NDK. The key differences:
|
||||
|
||||
1. **NDK already handles outbox routing** (the `outboxTracker` + temporary
|
||||
relays). Amethyst reimplements this in
|
||||
[`FollowListOutboxOrProxyRelays`](../amethyst/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt:455)
|
||||
because its quartz relay pool does not have NDK's automatic outbox model.
|
||||
You do **not** need to reimplement follow-outbox computation — you need to
|
||||
pre-warm it (Phases 1–3 above).
|
||||
|
||||
2. **NDK does not manage application-level relay lists** (DM, search, blocked,
|
||||
etc.). These are your responsibility. Amethyst has 11 state classes for
|
||||
these; you would need equivalent worker-side state and relay.html editor
|
||||
sections.
|
||||
|
||||
3. **The highest-ROI additions** are the Blocked list (because it integrates
|
||||
with NDK's `relayConnectionFilter` and improves outbox routing safety) and
|
||||
the DM list (because you already do NIP-17 DMs but don't advertise where
|
||||
to send them). These two alone would bring your client to functional
|
||||
parity with Amethyst for most users.
|
||||
|
||||
4. **The "many relay sets" feature** (kind 30002) is the most complex and
|
||||
lowest-ROI. Defer it unless you have a specific use case. Amethyst itself
|
||||
does not heavily surface custom relay sets in its main UI — they exist in
|
||||
the quartz library but the desktop `RelayConfigTab` only shows the 5
|
||||
fixed sections.
|
||||
|
||||
### Summary: what to do now vs. later
|
||||
|
||||
| Priority | Task | Effort | Depends on feed fix? |
|
||||
|---|---|---|---|
|
||||
| **Now** | Feed outbox pre-warm (Phases 1–3) | Small | — |
|
||||
| **Now** | Discovered relays read-only view in relay.html | Small | No |
|
||||
| **Next** | Blocked relay list (kind 10006) + `relayConnectionFilter` | Medium | No |
|
||||
| **Next** | DM relay list (kind 10050) | Medium | No |
|
||||
| **Later** | Search relay list (kind 10007) | Medium | No |
|
||||
| **Later** | Relay health UI | Medium | No |
|
||||
| **Later** | Per-event publish routing | Large | No |
|
||||
| **Later** | Broadcast relay list (NIP-51) | Medium | No |
|
||||
| **Maybe** | Trusted/Proxy/Indexer/RelayFeeds/PrivateStorage/KeyPackage lists | Large | No |
|
||||
| **Maybe** | Custom relay sets (kind 30002) | Large | No |
|
||||
|
||||
---
|
||||
|
||||
## relays.html page — current state and redesign plan
|
||||
|
||||
I examined [`www/relays.html`](www/relays.html) (1464 lines). Here is what it
|
||||
does today, the problems you identified, and a concrete plan for the page.
|
||||
|
||||
### Current structure
|
||||
|
||||
The page has two main areas:
|
||||
|
||||
1. **Relay table** ([`createRelayTable`](www/relays.html:553), lines 553–748) —
|
||||
A table with columns: Remove | Relay URL | Connected | Read | Write |
|
||||
DM Inbox | Reads | Writes | Connection Time. Each row is a relay from your
|
||||
kind 10002 list. The Read/Write checkboxes toggle the NIP-65 marker and
|
||||
republish kind 10002. The DM Inbox checkbox toggles membership in your
|
||||
kind 10050 list ([`publishDmInboxRelayList`](www/relays.html:1010)). There
|
||||
is an add-relay row at the bottom with the same toggles. The table
|
||||
refreshes every 5 seconds
|
||||
([`setInterval(refreshRelayData, 5000)`](www/relays.html:1446)).
|
||||
|
||||
2. **Connection history stream** ([`divRelayEvents`](www/relays.html:260),
|
||||
lines 859–939, 1200–1272) — A chat-like log of every relay frame
|
||||
(REQ/EVENT/OK/EOSE/NOTICE/AUTH/etc.) for the selected relay. Events arrive
|
||||
via `ndkRelayEvent` window events
|
||||
([line 1405](www/relays.html:1405)), which the worker broadcasts on every
|
||||
relay message via [`broadcastRelayEvent`](www/ndk-worker.js:917). The
|
||||
worker persists each event to IndexedDB
|
||||
([`writeRelayEventToDb`](www/ndk-worker.js:709)) and prunes to 200 per
|
||||
relay every 60s ([`RELAY_EVENT_LOG_LIMIT = 200`](www/ndk-worker.js:307),
|
||||
[`RELAY_EVENTS_PRUNE_INTERVAL_MS`](www/ndk-worker.js:311)). The page loads
|
||||
history from IndexedDB on relay selection
|
||||
([`loadRelayEventsFromDb`](www/relays.html:859)) and appends live events.
|
||||
|
||||
### Problem 1 — Connection history is heavy and slows the app
|
||||
|
||||
The performance issue has three layers:
|
||||
|
||||
1. **Worker-side: every relay frame is persisted to IndexedDB.**
|
||||
[`logRelayEvent`](www/ndk-worker.js:899) calls
|
||||
`void writeRelayEventToDb(entry)` on every single relay message. On an
|
||||
active feed with 5+ relays, this is dozens of IndexedDB writes per second.
|
||||
Each write opens a transaction, adds a row, and closes. This is fire-and-
|
||||
forget but the I/O contention on the `relay-events` IndexedDB database
|
||||
competes with the NDK cache adapter's own IndexedDB traffic.
|
||||
|
||||
2. **Worker-side: every relay frame is broadcast to all pages.**
|
||||
[`broadcastRelayEvent`](www/ndk-worker.js:917) sends every event to every
|
||||
connected port. Pages that don't care about relay events (feed.html,
|
||||
msg.html, etc.) still receive and dispatch them. On `relays.html` itself,
|
||||
the `ndkRelayEvent` listener
|
||||
([line 1405](www/relays.html:1405)) calls `logRelayEvent` which appends to
|
||||
the in-memory array and re-renders the entire event stream DOM
|
||||
([`renderRelayEventsForSelectedRelay`](www/relays.html:915)) on every
|
||||
frame.
|
||||
|
||||
3. **Page-side: full DOM re-render on every event.**
|
||||
`renderRelayEventsForSelectedRelay` does `divRelayEvents.innerHTML = ...`
|
||||
with up to 1000 entries ([line 1261](www/relays.html:1261) caps at 1000).
|
||||
On a busy relay, this is a full innerHTML replacement every few
|
||||
milliseconds. This is the direct cause of the slowdown you see.
|
||||
|
||||
### Solution: a toggle for connection history
|
||||
|
||||
The fix is to make the connection history opt-in, both on the page and in the
|
||||
worker. The toggle goes in the relays.html sidenav, replacing the placeholder
|
||||
text "Relay management options coming soon..."
|
||||
([`openNav`](www/relays.html:420) sets `divSideNavBody.innerHTML` to that
|
||||
string). It uses the same `sidenavSection` / `sidenavRowToggle` pattern as
|
||||
[`www/feed.html`](www/feed.html:99) uses for "Autoplay videos":
|
||||
|
||||
```html
|
||||
<div id="divRelaySettings" class="sidenavSection">
|
||||
<div class="sidenavSectionTitle">Relays</div>
|
||||
<label class="sidenavRowToggle" for="chkShowConnectionHistory">
|
||||
<span>Show connection history</span>
|
||||
<input id="chkShowConnectionHistory" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
This requires adding the
|
||||
[`sidenav-sections.css`](www/css/sidenav-sections.css) stylesheet to
|
||||
`relays.html` (it is not currently included — the page uses only
|
||||
`client.css`).
|
||||
|
||||
**Implementation:** This is covered by **Phase 1** (worker:
|
||||
`setRelayEventLogging` RPC + flag that stops both broadcasting and IndexedDB
|
||||
writes) and **Phase 4** (relays.html: sidenav toggle, show/hide the event
|
||||
stream, skip DB loads, incremental DOM updates) in the unified plan above.
|
||||
Default: **off** — the history is a debug tool.
|
||||
|
||||
### Problem 2 — Where to show outbox / discovered relays
|
||||
|
||||
You want to visualize the outbox model working. Today the table only shows
|
||||
your kind 10002 relays. After the feed outbox fix (Phases 1–3), NDK will be
|
||||
connecting to your follows' write relays as temporary relays — but they are
|
||||
invisible on this page.
|
||||
|
||||
The right design is a **second table below the main one**, clearly labeled as
|
||||
discovered/outbox relays, read-only.
|
||||
|
||||
These are implemented in **Phase 1** (worker RPC), **Phase 2** (page API),
|
||||
and **Phase 5** (relays.html table) of the unified plan above. The worker
|
||||
exposes `getDiscoveredRelays` (Phase 1.5–1.6), the page API wraps it (Phase
|
||||
2.3), and relays.html renders it in a collapsible "Discovered Relays
|
||||
(Outbox)" section with columns: Relay URL | Connected | Serving (count of
|
||||
follows) | Sample Follows (first 3 npubs). Read-only — no toggles, no
|
||||
remove. This is where you see the outbox model working: when you load the
|
||||
feed, this table populates with your follows' write relays. An optional
|
||||
"Outbox discovery" status line shows the outbox pool relays
|
||||
(`purplepag.es`, `nos.lol`) and their connection state.
|
||||
|
||||
### Problem 3 — Setting different relay types (Amethyst-style sections)
|
||||
|
||||
Today the table has Read / Write / DM Inbox columns. To reach Amethyst-level
|
||||
relay management, the page should grow **collapsible sections** for each
|
||||
relay list kind, rather than cramming everything into one table's columns.
|
||||
|
||||
Recommended layout (top to bottom):
|
||||
|
||||
1. **Your Relays (NIP-65, kind 10002)** — the current table, with Read/Write
|
||||
toggles. This is what you have now. Keep as-is.
|
||||
|
||||
2. **DM Inbox Relays (kind 10050)** — Currently a column in the main table.
|
||||
Move to its own section so it's not conflated with NIP-65 read/write.
|
||||
Each relay has a remove button. Add-relay input at the bottom. This is a
|
||||
refactor of the existing DM Inbox column into a dedicated section.
|
||||
|
||||
3. **Search Relays (kind 10007)** — New section. Editable list. Publishes
|
||||
kind 10007. Requires worker support to fetch/publish kind 10007 (new RPC).
|
||||
|
||||
4. **Blocked Relays (kind 10006)** — New section. Editable list. Publishes
|
||||
kind 10006. Requires worker support, and setting
|
||||
`ndk.relayConnectionFilter` so NDK refuses to connect to these relays.
|
||||
|
||||
5. **Discovered Relays (Outbox)** — Read-only, from Phase 5 above.
|
||||
|
||||
6. **Connection History** — The debug stream, behind the toggle from Phase 4.
|
||||
|
||||
Each section is collapsible (like Amethyst's
|
||||
[`CollapsibleSection`](../amethyst/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt:168))
|
||||
so the page doesn't become overwhelming. Default state: NIP-65 expanded, DM
|
||||
Inbox expanded, others collapsed.
|
||||
|
||||
### Phase 7 — relays.html: refactor DM Inbox into its own section
|
||||
|
||||
**File:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **7.1** Remove the "DM Inbox" column from the main relay table
|
||||
([`createRelayTable`](www/relays.html:553), line 591 header and line 642
|
||||
body cell).
|
||||
- [ ] **7.2** Add a new collapsible "DM Inbox Relays (kind 10050)" section
|
||||
below the main table. Each relay in `dmInboxRelays` gets a row with a
|
||||
remove button. Add-relay input at the bottom. Reuses the existing
|
||||
[`publishDmInboxRelayList`](www/relays.html:1010) logic.
|
||||
- [ ] **7.3** The add-relay row in the main table loses its DM Inbox toggle
|
||||
(line 658). DM inbox relays are added from the DM Inbox section instead.
|
||||
|
||||
### Phase 8 — relays.html: Search relays section (kind 10007)
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs), [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **8.1** Worker: add fetch/publish for kind 10007. Add a
|
||||
`handleGetSearchRelayList` handler that fetches kind 10007 for the
|
||||
current pubkey, and a `handlePublishSearchRelayList` handler that
|
||||
publishes a kind 10007 event with the relay list.
|
||||
- [ ] **8.2** Page API: add `getSearchRelayList()` and
|
||||
`publishSearchRelayList(relays)` exports in init-ndk.mjs.
|
||||
- [ ] **8.3** relays.html: Add a collapsible "Search Relays (kind 10007)"
|
||||
section. Editable list with add/remove. Loads from
|
||||
`getSearchRelayList()` on init, publishes via
|
||||
`publishSearchRelayList()` on change.
|
||||
|
||||
### Phase 9 — relays.html: Blocked relays section (kind 10006) + relayConnectionFilter
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs), [`www/relays.html`](www/relays.html)
|
||||
|
||||
- [ ] **9.1** Worker: add fetch/publish for kind 10006. Add a
|
||||
`handleGetBlockedRelayList` handler and a
|
||||
`handlePublishBlockedRelayList` handler.
|
||||
- [ ] **9.2** Worker: set `ndk.relayConnectionFilter` to skip blocked relays.
|
||||
When the blocked relay list is loaded/updated, set
|
||||
`ndk.relayConnectionFilter = (url) => !blockedRelays.has(normalizeRelayUrl(url))`.
|
||||
This makes NDK's outbox tracker skip blocked relays
|
||||
([`www/ndk-core.bundle.js:42835`](www/ndk-core.bundle.js:42835)) and
|
||||
prevents temporary relay connections to blocked relays.
|
||||
- [ ] **9.3** Page API: add `getBlockedRelayList()` and
|
||||
`publishBlockedRelayList(relays)` exports.
|
||||
- [ ] **9.4** relays.html: Add a collapsible "Blocked Relays (kind 10006)"
|
||||
section. Editable list with add/remove.
|
||||
|
||||
### Summary — all phases
|
||||
|
||||
| Phase | What | Effort | Depends on | Status |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Worker: warmOutbox + relay-event logging toggle + getDiscoveredRelays | Small | Nothing | ✅ Done (v0.7.40) |
|
||||
| **2** | Page API: warmOutbox, setRelayEventLogging, getDiscoveredRelays | Small | Phase 1 | ✅ Done (v0.7.40) |
|
||||
| **3** | Feed: pre-warm outbox before bootstrap | Small | Phase 2 | ✅ Done (v0.7.40) |
|
||||
| **4** | relays.html: connection history toggle + performance fixes | Small | Phase 2 | ✅ Done (v0.7.41) |
|
||||
| **5** | relays.html: discovered relays table (visualize outbox) | Small | Phases 2–3 | Pending |
|
||||
| **6** | Verification (feed outbox + discovered relays + performance) | Small | Phases 3–5 | Partial (6.1–6.3, 6.5 done via code review; 6.4 pending Phase 5) |
|
||||
| **7** | relays.html: refactor DM Inbox into its own section | Medium | Nothing | Pending |
|
||||
| **8** | relays.html: Search relays section (kind 10007) | Medium | Nothing | Pending |
|
||||
| **9** | relays.html: Blocked relays section (kind 10006) + relayConnectionFilter | Medium | Nothing | Pending |
|
||||
|
||||
**Phases 1–4 are complete** (deployed v0.7.40–v0.7.41) — they fix the feed
|
||||
outbox routing and the relays.html connection history performance issue.
|
||||
Phases 5–6 (discovered relays visualization) are the next priority. Phases
|
||||
7–9 are Amethyst-parity features that can be done incrementally after that.
|
||||
364
plans/feed-upgrade.md
Normal file
364
plans/feed-upgrade.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# Feed Upgrade — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
A series of improvements to the feed page and post interaction bar, centered on:
|
||||
1. **Simplified feed** — show posts without inline replies, just counts
|
||||
2. **Post thread page** — click comment button to open post with full comment thread in a new tab
|
||||
3. **Zap capability indicators** — show what the recipient can receive (nutzap, Lightning)
|
||||
4. **Zap rail selector** — let users choose between nutzap and Lightning melt
|
||||
5. **Balance check and error handling** — pre-flight checks and better error messages
|
||||
|
||||
The project uses **Cashu as the only payment rail** — nutzaps (NIP-61) for ecash transfers, and Cashu melt for Lightning zaps (NIP-57). No NWC, CLINK, or onchain needed.
|
||||
|
||||
### Current state
|
||||
|
||||
- [`www/feed.html`](www/feed.html) — shows posts with inline replies (toggled by "Comments" button). Replies appear under the post as they occur. This is unreliable.
|
||||
- [`www/feed2.html`](www/feed2.html) — copy of feed.html, to be modified per this plan
|
||||
- [`www/post.html`](www/post.html) — posting/composer page (NOT a thread viewer). Accepts `?event=`, `?npub=`, `?profile=` params.
|
||||
- [`www/note.html`](www/note.html) — long-form note editor (kind 30023/30024), NOT for kind 1 threads
|
||||
- [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) — renders the interaction bar (like, comment, quote, zap buttons) and handles zap routing
|
||||
- [`www/js/zaps.mjs`](www/js/zaps.mjs) — NIP-57 Lightning zap protocol
|
||||
|
||||
### URL schema (existing convention)
|
||||
|
||||
The project uses `URLSearchParams` with query parameters:
|
||||
- `?npub=<npub1...>` — Nostr public key (bech32)
|
||||
- `?pubkey=<hex>` — Nostr public key (hex)
|
||||
- `?event=<hex|nevent|note>` — Event identifier
|
||||
- `?auth=required|optional|none` — Auth mode
|
||||
|
||||
The new `post-feed.html` page will follow this schema: `post-feed.html?event=<hex|nevent|note>`
|
||||
|
||||
### CSS theme
|
||||
|
||||
The project uses CSS variables (never hardcode colors):
|
||||
- `--primary-color` (black in light mode, white in dark mode) — main text/icons
|
||||
- `--secondary-color` (white in light mode, black in dark mode) — background
|
||||
- `--accent-color` (#ff0000 red, both modes) — the **only** highlight color
|
||||
- `--muted-color` (#dddddd light / #777777 dark) — dimmed/disabled
|
||||
|
||||
All indicators must use these variables. "Colored" = `var(--accent-color)`, "dimmed" = `var(--muted-color)`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Simplified Feed (feed2.html)
|
||||
|
||||
Remove inline replies from the feed. Show only the original post with interaction counts (likes, comments, zaps, nutzaps). Clicking the comment button opens the post thread page in a **new tab**.
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **1.1** In `feed2.html`, remove the inline comment rendering and the "Comments" toggle button. The feed should only show top-level posts.
|
||||
|
||||
- [x] **1.2** Change the comment button behavior — instead of calling `onCommentIntentFn` to show inline comments, open `post-feed.html?event=<eventId>` in a **new tab** using `window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank')`.
|
||||
|
||||
- [x] **1.3** Keep the interaction bar functional in the feed:
|
||||
- ✅ Like button — works inline (no navigation)
|
||||
- ✅ Zap button — works inline (opens zap dialog, sends nutzap or Lightning melt)
|
||||
- ✅ Nutzap count display — shows total nutzaps received
|
||||
- ✅ Comment button — opens `post-feed.html?event=<eventId>` in a new tab
|
||||
- ✅ Quote button — opens quote composer (no navigation)
|
||||
|
||||
- [x] **1.4** Keep real-time updates for counts (likes, zaps, comments, nutzaps) — the feed should still update these counts as events arrive, just not render the reply content inline.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/feed2.html`](www/feed2.html) | Remove inline comment rendering, comment button opens post-feed.html in new tab |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Post Thread Page (post-feed.html)
|
||||
|
||||
Create a new `www/post-feed.html` page that shows a post with its full comment thread — all replies, with the ability to reply inline.
|
||||
|
||||
### URL schema
|
||||
|
||||
```
|
||||
post-feed.html?event=<hex|nevent|note>
|
||||
```
|
||||
|
||||
Accepts the same event identifier formats as the rest of the project:
|
||||
- Hex event ID: `post-feed.html?event=abc123...`
|
||||
- nevent: `post-feed.html?event=nevent1q...`
|
||||
- note: `post-feed.html?event=note1...`
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **2.1** Create `www/post-feed.html` based on the [`www/template.html`](www/template.html) pattern — standard header, hamburger, sidenav, footer.
|
||||
|
||||
- [x] **2.2** Parse the `event` URL parameter using `URLSearchParams` (same pattern as `post.html` line 248). Decode nevent/note to hex if needed.
|
||||
|
||||
- [x] **2.3** Fetch the original post event via the worker's `ndkFetchEvents` or `queryCache` function. Display it using `renderPostItem` from `post-interactions.mjs`.
|
||||
|
||||
- [x] **2.4** Fetch all replies — kind 1 events with an `e` tag pointing to the post ID. Use `ndkFetchEvents` with a filter like `{ kinds: [1], '#e': [postId] }`. **Updated:** Now uses recursive fetching (`fetchRepliesRecursive`) to find nested replies up to 5 levels deep.
|
||||
|
||||
- [x] **2.5** Render the comment thread below the original post:
|
||||
- Threaded with vertical indent lines (Phase 2.5)
|
||||
- Each reply rendered using `renderPostItem` from `post-interactions2.mjs`
|
||||
- Depth-first ordering with level computation from NIP-10 e tags
|
||||
|
||||
- [x] **2.6** Add an inline composer at the **top** (under the main post, before replies) for posting replies. The composer:
|
||||
- Tags the original post: `['e', postId, '', 'reply']`
|
||||
- Tags the original author: `['p', postPubkey]`
|
||||
- Uses the existing post composer component (`post-composer.mjs`)
|
||||
- Styled to match `post.html`'s composer (`.topPostComposerInput` CSS)
|
||||
|
||||
- [x] **2.7** Real-time updates — subscribe to new replies via the worker's subscription system. Append new replies to the thread as they arrive. New replies trigger a 1-level recursive fetch for their sub-replies.
|
||||
|
||||
- [x] **2.8** Each reply should have its own interaction bar (like, zap, quote) via `post-interactions2.mjs`. The comment button on a reply opens `post-feed.html?event=<replyId>` in a new tab for that reply's sub-thread.
|
||||
|
||||
### Files to create
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `www/post-feed.html` | New post thread page — shows post + all replies + inline composer |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2.5: Threaded Replies with Vertical Lines (post-feed.html)
|
||||
|
||||
Upgrade the flat reply list in `post-feed.html` to show **threaded replies with vertical indent lines**, matching Amethyst's thread view.
|
||||
|
||||
### How Amethyst does it
|
||||
|
||||
Amethyst uses two key components:
|
||||
|
||||
1. **`ThreadLevelCalculator.replyLevel(note, cachedLevels)`** — computes the depth of each reply recursively:
|
||||
- Root post (no `e` tag reply) → level 0
|
||||
- A reply's level = `max(parent's level for each parent) + 1`
|
||||
- Parents are determined by NIP-10 `e` tags in the event
|
||||
|
||||
2. **`Modifier.drawReplyLevel(level, color, selected)`** — draws vertical lines to the left of each post:
|
||||
- Draws `level` vertical lines, each 2px wide, spaced 3px apart
|
||||
- The last line (closest to the post) uses the "selected" color
|
||||
- All other lines use the "muted" color
|
||||
- The post content is padded left by `2 + (level * 3)px`
|
||||
|
||||
### Visual design
|
||||
|
||||
```
|
||||
Original post (level 0)
|
||||
│ Reply A (level 1) — direct reply to original
|
||||
│ │ Reply A1 (level 2) — reply to Reply A
|
||||
│ │ Reply A2 (level 2) — another reply to Reply A
|
||||
│ Reply B (level 1) — another direct reply to original
|
||||
│ │ Reply B1 (level 2) — reply to Reply B
|
||||
│ │ │ Reply B1a (level 3) — reply to Reply B1
|
||||
│ Reply C (level 1) — yet another direct reply
|
||||
```
|
||||
|
||||
Each `│` is a vertical line drawn with CSS `border-left` on a container div. The lines use:
|
||||
- `var(--muted-color)` for non-selected levels
|
||||
- `var(--accent-color)` for the current post's level (the post being viewed)
|
||||
|
||||
### Tasks
|
||||
|
||||
- [x] **2.5.1** Add a `computeReplyLevels(events, rootEventId)` function to `post-feed.html`:
|
||||
- Build a map of `eventId → parentEventId` from the `e` tags (NIP-10 marked reply tags)
|
||||
- For each event, compute its level by walking up the parent chain to the root
|
||||
- Root post = level 0, direct reply = level 1, reply to reply = level 2, etc.
|
||||
- Handle missing parents gracefully (if a parent isn't in the fetched set, treat as level 1)
|
||||
- Return a `Map<eventId, level>`
|
||||
|
||||
- [x] **2.5.2** Sort replies in depth-first order (like Amethyst's `replyLevelSignature`):
|
||||
- Root post first
|
||||
- Then replies grouped by parent, in chronological order within each group
|
||||
- This makes the vertical lines visually contiguous — a reply's children appear right below it
|
||||
|
||||
- [x] **2.5.3** Render each reply with vertical indent lines:
|
||||
- Wrap each reply in a container div with `padding-left: calc(level * 12px)`
|
||||
- For each level 0..N-1, add a vertical line using positioned divs with `background: var(--muted-color)`
|
||||
- The last line (level N-1) uses `var(--accent-color)` if this is the focused post, otherwise `var(--muted-color)`
|
||||
|
||||
- [x] ~~**2.5.4** Add collapse/expand functionality~~ — **Removed per user request.** All replies are shown, no collapse.
|
||||
|
||||
- [x] **2.5.5** Highlight the focused post:
|
||||
- If the URL has a `focus=<eventId>` param, scroll to and highlight that post
|
||||
- The highlighted post's vertical line uses `var(--accent-color)` and card gets `var(--accent-color)` outline
|
||||
|
||||
### CSS approach (CSS variables only)
|
||||
|
||||
```css
|
||||
.reply-thread-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--muted-color);
|
||||
}
|
||||
|
||||
.reply-thread-line.selected {
|
||||
background: var(--accent-color);
|
||||
}
|
||||
|
||||
.reply-item {
|
||||
position: relative;
|
||||
padding-left: 12px; /* per level */
|
||||
}
|
||||
```
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/post-feed.html`](www/post-feed.html) | Add level computation, depth-first sorting, vertical line rendering, collapse/expand |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Zap Capability Indicators in the Feed
|
||||
|
||||
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
|
||||
|
||||
### Design
|
||||
|
||||
Compact indicators next to the existing zap button, using the project's CSS variables:
|
||||
|
||||
| Indicator | CSS | Meaning |
|
||||
|-----------|-----|---------|
|
||||
| 🥜 (accent color) | `color: var(--accent-color)` | Recipient can receive nutzaps — has kind 10019 with a shared mint |
|
||||
| 🥜 (muted) | `color: var(--muted-color)` | Recipient has kind 10019 but no shared mint with your wallet |
|
||||
| ⚡ (accent color) | `color: var(--accent-color)` | Recipient can receive Lightning zaps — has lud16 |
|
||||
| ⚡ (muted) | `color: var(--muted-color)` | Recipient has no lud16 — cannot receive Lightning zaps |
|
||||
|
||||
Tooltip on hover shows details: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability".
|
||||
|
||||
### When and where to fetch the data
|
||||
|
||||
Two-tier strategy based on whether the author is a followed user or not:
|
||||
|
||||
**Tier 1: Followed users (proactive fetch on startup)**
|
||||
- The worker already fetches the user's kind 3 (contact list) on startup (line 1799 of `ndk-worker.js`)
|
||||
- After the kind 3 is loaded, proactively fetch kind 10019 for all followed pubkeys in a batch
|
||||
- This happens once on startup, results cached in the worker's Dexie/IndexedDB
|
||||
- Followed users are the most likely zap recipients, and their 10019 rarely changes
|
||||
- Add a subscription for kind 10019 updates for followed pubkeys (so changes are picked up)
|
||||
|
||||
**Tier 2: Non-followed users (lazy fetch on first encounter)**
|
||||
- When a post by a non-followed user appears in the feed, lazily fetch their kind 10019
|
||||
- Use the existing `walletFetchMintList` worker function
|
||||
- Cache the result per pubkey in an in-memory Map with TTL (5 minutes)
|
||||
- Only fetch once per pubkey per session (unless cache expires)
|
||||
|
||||
**What's already available (no fetch needed):**
|
||||
- lud16 (Lightning address) — already cached in `profile-cache.mjs` when post headers are rendered. The profile fetch already happens for every post in the feed.
|
||||
- The sender's own wallet mints — already in the worker's `directProofStore`, accessible via `walletGetMints`
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **3.1** Create a `resolveZapCapabilities(pubkey)` function in [`www/js/zaps.mjs`](www/js/zaps.mjs) that checks:
|
||||
- Does the recipient have a lud16 (Lightning address)? → check `profile-cache.mjs` cache (already fetched for post rendering) → `canLightning: true`
|
||||
- Does the recipient have a kind 10019 (nutzap mint list)? → check local cache first, then fetch via `walletFetchMintList` if not cached
|
||||
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints (from `walletGetMints`)? → `canNutzap: true`, `sharedMints: [...]`
|
||||
- Return `{ canLightning, canNutzap, sharedMints, nutzapMints, lud16 }`
|
||||
|
||||
- [ ] **3.2** Add a proactive kind 10019 batch fetch for followed users in the worker:
|
||||
- After the kind 3 (contact list) is loaded on startup, collect all followed pubkeys
|
||||
- Fetch kind 10019 for all followed pubkeys: `ndk.fetchEvents({ kinds: [10019], authors: [...followedPubkeys] })`
|
||||
- Cache the results in the worker's IndexedDB so they persist across sessions
|
||||
- Add a subscription for kind 10019 updates for followed pubkeys
|
||||
|
||||
- [ ] **3.3** For non-followed users, lazily fetch kind 10019 via `walletFetchMintList`:
|
||||
- Cache results in an in-memory Map with TTL (5 minutes) in `zaps.mjs`
|
||||
- Only fetch if not already in the worker's IndexedDB cache or the in-memory cache
|
||||
- Non-blocking: render the interaction bar without badges, then update when the fetch completes
|
||||
|
||||
- [ ] **3.4** Update the zap button rendering in `post-interactions.mjs` to show capability indicators:
|
||||
- Add a small badge element next to the zap button
|
||||
- Show 🥜 in `var(--accent-color)` if `canNutzap` is true
|
||||
- Show 🥜 in `var(--muted-color)` if recipient has 10019 but no shared mint
|
||||
- Show ⚡ in `var(--accent-color)` if `canLightning` is true
|
||||
- Add tooltip with shared mint details
|
||||
|
||||
- [ ] **3.5** Make the capability check async and non-blocking — render the interaction bar immediately, then update the badges when the capability check completes. The lud16 check is instant (from profile cache); the 10019 check may take a moment for non-followed users.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` with caching, lazy fetch for non-followed users |
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | Add proactive kind 10019 batch fetch for followed users on startup |
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Zap Rail Selector in the Zap Dialog
|
||||
|
||||
When the user taps the zap button, show which rails are available and let them choose.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User taps zap button] --> B[Show zap dialog with amount input]
|
||||
B --> C{What rails are available?}
|
||||
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
|
||||
C -->|Lightning only| E[Show Lightning only]
|
||||
C -->|Nutzap only| F[Show Nutzap only]
|
||||
C -->|Neither| G[Show error: recipient cannot receive zaps]
|
||||
D --> H[User selects rail + amount]
|
||||
H --> I{Rail selected}
|
||||
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
|
||||
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
|
||||
E --> K
|
||||
F --> J
|
||||
```
|
||||
|
||||
### Tasks
|
||||
|
||||
- [ ] **4.1** Update `promptZapDetails` in [`www/js/zaps.mjs`](www/js/zaps.mjs) to accept and display rail options:
|
||||
- If nutzap is available → show "🥜 Nutzap" option
|
||||
- If Lightning is available → show "⚡ Lightning" option
|
||||
- Let the user pick which rail to use via a toggle/selector
|
||||
- Selected rail uses `var(--accent-color)`, unselected uses `var(--muted-color)`
|
||||
|
||||
- [ ] **4.2** Show balance info in the dialog: "Cashu balance: 261 sats". If amount > balance → show warning in `var(--accent-color)`.
|
||||
|
||||
- [ ] **4.3** Default rail selection:
|
||||
- If nutzap is available and amount < 1000 sats → default to nutzap (lower fees)
|
||||
- If nutzap is available and amount ≥ 1000 sats → default to Lightning
|
||||
- If nutzap is not available → default to Lightning
|
||||
|
||||
- [ ] **4.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
|
||||
|
||||
- [ ] **4.5** Show shared mint info when nutzap is selected: "Will send via mint.minibits.cash/Bitcoin"
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Update `promptZapDetails` with rail selector UI |
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Update `handleZapClick` to respect user's rail choice |
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Balance Check and Error Handling
|
||||
|
||||
- [ ] **5.1** Before sending a zap, check the user's Cashu balance (via `walletGetBalance`):
|
||||
- If balance < zap amount → show "Insufficient balance. Current: X sats, needed: Y sats" in `var(--accent-color)`
|
||||
- If balance is sufficient but barely → show confirmation "This will use most of your balance. Continue?"
|
||||
|
||||
- [ ] **5.2** Improve error messages for melt failures:
|
||||
- "Mint couldn't route Lightning payment" (mint liquidity issue)
|
||||
- "Mint returned an error: [details]" (mint API error)
|
||||
- "Proofs were spent but payment failed — try again or contact the mint"
|
||||
|
||||
- [ ] **5.3** After a successful zap, refresh the balance display.
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add balance check before zap, improve error handling |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** (simplified feed) — remove inline replies from feed2.html, comment button opens post-feed.html in new tab
|
||||
2. **Phase 2** (post thread page) — create post-feed.html to show full post + comment thread
|
||||
3. **Phase 3** (zap capability indicators) — show what the recipient can receive
|
||||
4. **Phase 4** (rail selector) — let users choose nutzap vs Lightning melt
|
||||
5. **Phase 5** (balance + errors) — pre-flight checks and better error messages
|
||||
|
||||
Phases 1-2 are the feed restructuring. Phases 3-5 are zap improvements.
|
||||
507
plans/long-running-memory-bloat-fix.md
Normal file
507
plans/long-running-memory-bloat-fix.md
Normal file
@@ -0,0 +1,507 @@
|
||||
# Long-Running Memory Bloat & Unresponsiveness Fix Plan
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When the client runs for extended periods, pages become bloated and unresponsive. The root causes fall into four categories:
|
||||
|
||||
1. **Unbounded in-memory growth** — arrays/Maps/Sets that accumulate events without eviction
|
||||
2. **Listener/event accumulation** — DOM listeners and worker subscriptions that are never cleaned up
|
||||
3. **Over-aggressive polling** — 1-second intervals on every page, each triggering multiple worker round-trips
|
||||
4. **Full re-renders** — `innerHTML = ''` + rebuild on every incoming event, discarding DOM and re-attaching all listeners
|
||||
|
||||
The SharedWorker broadcasts every subscription event to every connected tab, and pages process all of them regardless of which subscription they originated from. Combined with full-DOM-rebuild rendering, this causes O(n) work on every event and linear memory growth over the session lifetime.
|
||||
|
||||
## Scope
|
||||
|
||||
**Pages affected:** `index.html`, `feed.html`, `post.html`, `notifications.html`, `relays.html`
|
||||
**Worker affected:** `ndk-worker.js`
|
||||
**Shared module affected:** `js/init-ndk.mjs`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Worker[ndk-worker.js SharedWorker]
|
||||
sub[activeSubscriptions Map]
|
||||
stats[relayActivityStats Map]
|
||||
ports[connectedPorts array]
|
||||
wallet[lightwalletTokenEvents Map]
|
||||
broadcast[broadcast to all ports]
|
||||
end
|
||||
|
||||
subgraph Tabs[Open Tabs - each gets ALL broadcasts]
|
||||
index[index.html]
|
||||
feed[feed.html]
|
||||
post[post.html]
|
||||
notif[notifications.html]
|
||||
relays[relays.html]
|
||||
end
|
||||
|
||||
sub -->|event/eose per sub| broadcast
|
||||
stats -->|relayActivity per read/write| broadcast
|
||||
broadcast -->|unfiltered| index
|
||||
broadcast -->|unfiltered| feed
|
||||
broadcast -->|unfiltered| post
|
||||
broadcast -->|unfiltered| notif
|
||||
broadcast -->|unfiltered| relays
|
||||
|
||||
feed -->|posts array grows forever| MemLeak[Memory Leak]
|
||||
notif -->|notificationsById grows forever| MemLeak
|
||||
notif -->|document.click leaked per row| ListenerLeak[Listener Leak]
|
||||
feed -->|innerHTML rebuild per event| CPUSpike[CPU Spike]
|
||||
relays -->|table rebuild every 5s| CPUSpike
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: P0 — Critical Rendering & Listener Leaks
|
||||
|
||||
**Goal:** Eliminate the two most severe causes of user-visible jank and memory growth.
|
||||
|
||||
#### 1.1 Append-only rendering for feed and post pages
|
||||
|
||||
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
|
||||
|
||||
**Current behavior:** [`renderFeed()`](www/feed.html:460) does `divFeed.innerHTML = ''` and rebuilds all cards on every incoming event.
|
||||
|
||||
**Changes:**
|
||||
- Split `renderFeed()` into two functions:
|
||||
- `renderFeed()` — full rebuild, only called on initial load, "See More", sort changes, or mute removal
|
||||
- `prependPostCard(post)` — creates a single card, inserts it at the top of `divFeed` if newer than the first visible post, and wires interactions for just that one card
|
||||
- In the `ndkEvent` handler, call `prependPostCard(evt)` instead of `renderFeed()` when a new kind-1 event arrives and the feed is already rendered
|
||||
- Cap the visible DOM: if `divFeed.children.length > displayCount + SEE_MORE_INCREMENT`, remove the last child
|
||||
- Keep `posts[]` sorted by inserting new events in the correct position (binary insert or simple unshift + periodic sort)
|
||||
|
||||
**Acceptance criteria:**
|
||||
- New posts appear at the top without rebuilding existing cards
|
||||
- Existing images/videos are not reloaded when a new post arrives
|
||||
- "See More" still does a full re-render to show additional posts
|
||||
|
||||
#### 1.2 Append-only rendering for notifications page
|
||||
|
||||
**Files:** [`www/notifications.html`](www/notifications.html)
|
||||
|
||||
**Current behavior:** [`renderNotifications()`](www/notifications.html:886) does `divNotifications.innerHTML = ''` and rebuilds all rows on every new notification.
|
||||
|
||||
**Changes:**
|
||||
- Split into `renderNotifications()` (full rebuild for filter changes / "View more") and `prependNotificationRow(item)` (single new row)
|
||||
- In `addNotificationFromEvent`, call `prependNotificationRow` when the notifications list is already rendered and the new item passes the filter
|
||||
- Cap visible DOM to `visibleNotificationsCount + NOTIFICATIONS_PAGE_SIZE`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- New notifications appear without rebuilding existing rows
|
||||
- Filter toggles and "View more" still do full re-renders
|
||||
|
||||
#### 1.3 Fix notification `document.click` listener leak
|
||||
|
||||
**Files:** [`www/notifications.html`](www/notifications.html)
|
||||
|
||||
**Current behavior:** [`renderNotifications()`](www/notifications.html:1046) registers a `document.addEventListener('click', ...)` for **every** notification row, and these are never removed.
|
||||
|
||||
**Changes:**
|
||||
- Remove the per-row `document.addEventListener('click', ...)` entirely
|
||||
- Register a **single** document-level click listener in `main()` that closes any open menu panel by checking `document.querySelector('.notif-menu-btn[aria-expanded="true"]')` or similar
|
||||
- Alternatively, use a module-level `openMenuPanel` variable; the single listener checks if the click target is outside `openMenuPanel` and closes it
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Only one document click listener exists regardless of notification count
|
||||
- Menu panels still close when clicking outside
|
||||
|
||||
#### 1.4 Cap `posts[]` and `postIds` with rolling eviction
|
||||
|
||||
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
|
||||
|
||||
**Current behavior:** `posts` array and `postIds` Set grow without bound.
|
||||
|
||||
**Changes:**
|
||||
- Add `const MAX_STORED_POSTS = 500` constant
|
||||
- In `upsertFeedPost` / `upsertPost`, after pushing a new event, if `posts.length > MAX_STORED_POSTS`, sort by `created_at` descending, slice to `MAX_STORED_POSTS`, and rebuild `postIds` from the remaining array
|
||||
- Also remove evicted post IDs from `renderedPostIds`
|
||||
- In `post.html` event mode, skip this (single event display)
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `posts.length` never exceeds `MAX_STORED_POSTS`
|
||||
- Evicted posts are removed from `postIds` and `renderedPostIds`
|
||||
- "See More" still works within the capped array
|
||||
|
||||
#### 1.5 Cap `notificationsById` and `unreadNotificationsById` with periodic pruning
|
||||
|
||||
**Files:** [`www/notifications.html`](www/notifications.html)
|
||||
|
||||
**Current behavior:** Both Maps grow without bound; [`pruneNotificationsByCurrentSettings`](www/notifications.html:570) only runs on filter toggles.
|
||||
|
||||
**Changes:**
|
||||
- Add `const MAX_STORED_NOTIFICATIONS = 500`
|
||||
- Add a `pruneNotificationMaps()` function that:
|
||||
- Sorts `notificationsById` entries by `created_at` descending
|
||||
- Keeps only the top `MAX_STORED_NOTIFICATIONS`
|
||||
- Deletes evicted entries from both `notificationsById` and `unreadNotificationsById`
|
||||
- Also prunes `postImageCache` to an LRU of 200 entries
|
||||
- Call `pruneNotificationMaps()` at the end of `addNotificationFromEvent` if `notificationsById.size > MAX_STORED_NOTIFICATIONS + 100`
|
||||
- Also call it after `hydrateNotificationsFromCache` and `hydrateNotificationsFromRelays`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `notificationsById.size` never exceeds `MAX_STORED_NOTIFICATIONS + 100`
|
||||
- `postImageCache.size` never exceeds 200
|
||||
- Filter counts remain accurate after pruning
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: P1 — Subscription Hygiene & Polling Reduction
|
||||
|
||||
**Goal:** Stop the cross-tab event storm and reduce worker message queue saturation.
|
||||
|
||||
#### 2.1 Filter `ndkEvent` by `subId` on the page side
|
||||
|
||||
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html), [`www/notifications.html`](www/notifications.html), [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs)
|
||||
|
||||
**Current behavior:** The worker includes `subId` in event broadcasts ([`ndk-worker.js:1094`](www/ndk-worker.js:1094)), but pages' `ndkEvent` handlers process all events regardless of source subscription.
|
||||
|
||||
**Changes:**
|
||||
- In `init-ndk.mjs`, the `ndkEvent` CustomEvent already includes the full `message.data` — verify `subId` is in `event.detail`
|
||||
- Each page tracks its own active subscription IDs in a `Set`:
|
||||
- `feed.html`: track the kind-3 sub and the live feed sub
|
||||
- `post.html`: track the kind-1 author/event sub
|
||||
- `notifications.html`: track the notifications sub
|
||||
- In the `ndkEvent` handler, check `if (!mySubscriptionIds.has(event.detail.subId)) return;` before processing
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Pages only process events from their own subscriptions
|
||||
- Events from other tabs' subscriptions are ignored (no CPU work, no DOM updates)
|
||||
|
||||
#### 2.2 Close old live subscriptions on follow-list change
|
||||
|
||||
**Files:** [`www/feed.html`](www/feed.html)
|
||||
|
||||
**Current behavior:** [`ensureLiveFeedSubscription`](www/feed.html:548) creates a new subscription when follows change but never closes the old one.
|
||||
|
||||
**Changes:**
|
||||
- Add a module-level `let liveFeedSub = null;` variable
|
||||
- In `ensureLiveFeedSubscription`, before creating the new subscription:
|
||||
```js
|
||||
if (liveFeedSub?.close) {
|
||||
liveFeedSub.close();
|
||||
}
|
||||
```
|
||||
- Assign the return of `subscribe()` to `liveFeedSub`
|
||||
- Also track the kind-3 subscription similarly and close it on logout
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Only one live feed subscription exists at a time in the worker
|
||||
- `activeSubscriptions` Map in the worker doesn't accumulate stale feed subscriptions
|
||||
|
||||
#### 2.3 Increase footer polling interval to 5 seconds (or event-driven)
|
||||
|
||||
**Files:** [`www/index.html`](www/index.html), [`www/feed.html`](www/feed.html), [`www/notifications.html`](www/notifications.html), [`www/relays.html`](www/relays.html)
|
||||
|
||||
**Current behavior:** `setInterval(UpdateFooter, 1000)` on every page.
|
||||
|
||||
**Changes:**
|
||||
- Change all 1-second footer intervals to 5 seconds (matching `post.html` which already uses 5s)
|
||||
- `index.html` line 850: `setInterval(UpdateFooter, 5000)`
|
||||
- `feed.html` line 692: `setInterval(updateFooter, 5000)`
|
||||
- `notifications.html` line 1390: `setInterval(UpdateFooter, 5000)`
|
||||
- `relays.html` line 1794: `setInterval(UpdateFooter, 5000)`
|
||||
- **Future enhancement (not in this phase):** Make footer fully event-driven by reacting to `ndkRelays` and `ndkRelayActivity` events instead of polling
|
||||
|
||||
**Acceptance criteria:**
|
||||
- No page polls the worker for footer data more than once per 5 seconds
|
||||
- Footer still shows current relay status (5s staleness is acceptable)
|
||||
|
||||
#### 2.4 Diff-update relay table instead of full rebuild
|
||||
|
||||
**Files:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
**Current behavior:** [`createRelayTable`](www/relays.html:795) does `divRelays.innerHTML = html` every 5 seconds, destroying all rows, inputs, and listeners.
|
||||
|
||||
**Changes:**
|
||||
- Split `createRelayTable` into:
|
||||
- `buildRelayTable(relays)` — initial build (full innerHTML)
|
||||
- `updateRelayTableRows(relays)` — in-place update of existing rows:
|
||||
- Update status icon, read/write counts, connection time text
|
||||
- Update checkbox SVGs if relay type changed
|
||||
- Add/remove rows if relay count changed
|
||||
- Preserve the add-relay input value and focus
|
||||
- `refreshRelayData` calls `updateRelayTableRows` if the table already exists and the relay count matches; otherwise calls `buildRelayTable`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Relay table updates without losing input focus or re-attaching all listeners
|
||||
- Add-relay input value is preserved across refreshes (already partially implemented)
|
||||
|
||||
#### 2.5 Throttle `relayActivity` broadcasts in the worker
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
**Current behavior:** [`trackRelayRead`](www/ndk-worker.js:828) and [`trackRelayWrite`](www/ndk-worker.js:850) broadcast `relayActivity` on every single event read/write.
|
||||
|
||||
**Changes:**
|
||||
- Add a `relayActivityBroadcastTimer` and a `pendingRelayActivity` Set of relay URLs that have had activity since the last broadcast
|
||||
- In `trackRelayRead` / `trackRelayWrite`, add the relay URL to `pendingRelayActivity` and schedule a 500ms batched broadcast if not already scheduled
|
||||
- The batched broadcast sends one `relayActivity` message per pending relay with current stats, then clears the set
|
||||
- This reduces broadcast frequency from per-event to per-500ms-batch
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `relayActivity` broadcasts are batched to at most once per 500ms
|
||||
- Stats counts remain accurate (they're cumulative)
|
||||
- Footer/sidenav relay indicators still update within 1s of activity
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: P2 — Worker Cleanup & Cache Eviction
|
||||
|
||||
**Goal:** Prevent unbounded growth in worker-side state and clean up dead connections.
|
||||
|
||||
#### 3.1 Prune `connectedPorts` on tab close
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
**Current behavior:** [`connectedPorts`](www/ndk-worker.js:260) accumulates dead ports; [`broadcast()`](www/ndk-worker.js:2479) iterates all of them.
|
||||
|
||||
**Changes:**
|
||||
- In the `onconnect` handler ([`ndk-worker.js:7600`](www/ndk-worker.js:7600)), add:
|
||||
```js
|
||||
port.onclose = () => {
|
||||
connectedPorts = connectedPorts.filter(p => p !== port);
|
||||
if (activeSigningPort === port) activeSigningPort = null;
|
||||
};
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `connectedPorts` only contains live ports
|
||||
- `broadcast()` doesn't iterate dead ports
|
||||
|
||||
#### 3.2 Evict `inlineComposerInstances` on re-render
|
||||
|
||||
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html)
|
||||
|
||||
**Current behavior:** `inlineComposerInstances` Map retains entries with detached `hostEl` after `renderFeed()` clears the container.
|
||||
|
||||
**Changes:**
|
||||
- At the start of `renderFeed()` (the full rebuild path), after `divFeed.innerHTML = ''`:
|
||||
```js
|
||||
inlineComposerInstances.clear();
|
||||
```
|
||||
- Since the DOM is rebuilt, all composer host elements are gone; the Map entries are pure garbage
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `inlineComposerInstances` is empty after a full re-render
|
||||
- Composers can still be re-created on demand via `getOrCreateInlineComposer`
|
||||
|
||||
#### 3.3 Prune temporary relay Maps after broadcast publish
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
**Current behavior:** `relayActivityStats`, `relayConnectAttemptStartedAt`, `relayLastDisconnectMeta` accumulate entries for every temporary broadcast relay.
|
||||
|
||||
**Changes:**
|
||||
- After the broadcast publish `phase: 'done'` block ([`ndk-worker.js:6410`](www/ndk-worker.js:6410)), add a cleanup step:
|
||||
```js
|
||||
const userRelayUrls = new Set(Array.from(relayTypes.keys()).map(normalizeRelayUrl));
|
||||
for (const url of relayActivityStats.keys()) {
|
||||
if (!userRelayUrls.has(url)) relayActivityStats.delete(url);
|
||||
}
|
||||
relayConnectAttemptStartedAt.clear(); // only used during active connect attempts
|
||||
// Keep relayLastDisconnectMeta for reconnect detection
|
||||
```
|
||||
- Only prune `relayActivityStats` — the disconnect meta is needed for reconnect subscription rebuilding
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `relayActivityStats` only contains entries for the user's own relays after a broadcast completes
|
||||
- Reconnect detection still works (disconnect meta preserved)
|
||||
|
||||
#### 3.4 Periodic reconciliation of `lightwalletTokenEvents`
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
**Current behavior:** `lightwalletTokenEvents` and `lightwalletDeletedIds` grow monotonically; deleted entries are only removed during `publishDirectProofs`.
|
||||
|
||||
**Changes:**
|
||||
- Add a 5-minute interval timer `lightwalletReconcileTimer` that:
|
||||
- Iterates `lightwalletTokenEvents` and removes any entry whose id is in `lightwalletDeletedIds`
|
||||
- Optionally trims `lightwalletDeletedIds` to the most recent 500 entries (keep recent ones to prevent re-processing)
|
||||
- Clear this timer in `handleDisconnect`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- `lightwalletTokenEvents` doesn't retain deleted token events
|
||||
- `lightwalletDeletedIds` is bounded to ~500 entries
|
||||
|
||||
#### 3.5 Clear health-check and prune intervals on disconnect
|
||||
|
||||
**Files:** [`www/ndk-worker.js`](www/ndk-worker.js)
|
||||
|
||||
**Current behavior:** `startRelayHealthCheck` ([`ndk-worker.js:1262`](www/ndk-worker.js:1262)) and `scheduleRelayEventPruning` ([`ndk-worker.js:789`](www/ndk-worker.js:789)) create intervals that are never cleared.
|
||||
|
||||
**Changes:**
|
||||
- Store the health-check interval ID in a module-level variable `relayHealthCheckTimer`
|
||||
- Store the prune interval ID in `relayEventsPruneTimer` (already exists)
|
||||
- In `handleDisconnect()` ([`ndk-worker.js:7350`](www/ndk-worker.js:7350)), add:
|
||||
```js
|
||||
if (relayHealthCheckTimer) { clearInterval(relayHealthCheckTimer); relayHealthCheckTimer = null; }
|
||||
if (relayEventsPruneTimer) { clearInterval(relayEventsPruneTimer); relayEventsPruneTimer = null; }
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- No duplicate intervals after re-init
|
||||
- Intervals are cleared on disconnect
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: P3 — Minor Leaks & Polish
|
||||
|
||||
**Goal:** Clean up remaining minor leaks and harden against edge cases.
|
||||
|
||||
#### 4.1 Clear `refreshTimes` interval on notifications logout
|
||||
|
||||
**Files:** [`www/notifications.html`](www/notifications.html)
|
||||
|
||||
**Current behavior:** [`setInterval(refreshTimes, 30000)`](www/notifications.html:1392) is never assigned to a variable and can't be cleared.
|
||||
|
||||
**Changes:**
|
||||
- Assign to `let refreshTimesIntervalId = setInterval(refreshTimes, 30000)`
|
||||
- Clear it in `Logout()`
|
||||
|
||||
#### 4.2 Remove duplicate `window.addEventListener('message', ...)` in relays.html
|
||||
|
||||
**Files:** [`www/relays.html`](www/relays.html)
|
||||
|
||||
**Current behavior:** [`relays.html:1777`](www/relays.html:1777) has a fallback `message` listener that duplicates the `ndkRelayActivity` handler.
|
||||
|
||||
**Changes:**
|
||||
- Remove the `window.addEventListener('message', ...)` block at lines 1777–1783
|
||||
- The `ndkRelayActivity` handler at line 1762 already covers this
|
||||
|
||||
#### 4.3 Add `beforeunload` cleanup for subscriptions on all pages
|
||||
|
||||
**Files:** [`www/feed.html`](www/feed.html), [`www/post.html`](www/post.html), [`www/notifications.html`](www/notifications.html)
|
||||
|
||||
**Current behavior:** Pages don't close their subscriptions on `beforeunload`, leaving stale entries in the worker's `activeSubscriptions` Map until the worker detects the port close.
|
||||
|
||||
**Changes:**
|
||||
- Add `window.addEventListener('beforeunload', () => { disconnect(); })` (or close individual subscriptions) on each page
|
||||
- The `disconnect()` function in `init-ndk.mjs` already sends a `disconnect` message and closes the port
|
||||
|
||||
**Acceptance criteria:**
|
||||
- Worker's `activeSubscriptions` is cleaned up when a tab closes
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Testing
|
||||
1. Open `index.html`, `feed.html`, `post.html`, `notifications.html`, `relays.html` in separate tabs
|
||||
2. Leave them running for 30+ minutes with active subscriptions
|
||||
3. Check browser DevTools Memory tab:
|
||||
- Take heap snapshots at 5min, 15min, 30min
|
||||
- Verify heap growth is sublinear (not growing with event count)
|
||||
4. Check Performance tab:
|
||||
- Record a 30s profile while events are flowing
|
||||
- Verify no full `renderFeed` / `renderNotifications` on every event
|
||||
5. Check that relay footer updates within 5s of connection changes
|
||||
6. Check that notification menu panels still open/close correctly
|
||||
|
||||
### Automated Checks
|
||||
- After each phase, run the existing test suite (`tests/broadcast-relay-test.sh` etc.)
|
||||
- Add a memory regression test if feasible (puppeteer heap snapshot comparison)
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Phase | Risk | Mitigation |
|
||||
|-------|------|------------|
|
||||
| Phase 1 (append-only) | Sort order bugs if new event is older than visible posts | Only prepend if newer than first visible; fall back to full render otherwise |
|
||||
| Phase 1 (listener fix) | Menu panel might not close in all cases | Test thoroughly; use single delegated listener |
|
||||
| Phase 2 (subId filter) | Might miss events if subId tracking is wrong | Log filtered vs processed events during testing |
|
||||
| Phase 2 (polling change) | Footer might feel less responsive | 5s is still fast; relay status rarely changes in seconds |
|
||||
| Phase 3 (worker cleanup) | Might prune stats too aggressively | Only prune temp relay stats, not user's own relays |
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Phases |
|
||||
|------|--------|
|
||||
| `www/feed.html` | 1, 2, 3, 4 |
|
||||
| `www/post.html` | 1, 2, 3, 4 |
|
||||
| `www/notifications.html` | 1, 2, 3, 4 |
|
||||
| `www/relays.html` | 2, 4 |
|
||||
| `www/index.html` | 2 |
|
||||
| `www/ndk-worker.js` | 2, 3 |
|
||||
| `www/js/init-ndk.mjs` | 2 |
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Phase 1: P0 — Critical Rendering & Listener Leaks
|
||||
|
||||
- [x] **1.1** Append-only rendering for feed and post pages
|
||||
- [x] Split `renderFeed()` into full rebuild + `prependPostCard()`
|
||||
- [x] New posts prepend without rebuilding existing cards
|
||||
- [x] Cap visible DOM to `displayCount` (trims last child on overflow)
|
||||
- [x] "See More" still does full re-render
|
||||
- [x] Verified: `data-post-id` attribute set by `renderPostItem` for trim logic
|
||||
- [x] Reviewed: no errors found, fallback paths correct
|
||||
- [x] **1.2** Append-only rendering for notifications page
|
||||
- [x] Split `renderNotifications()` into full rebuild + `prependNotificationRow()`
|
||||
- [x] Extracted `buildNotificationRow(item)` helper shared by both functions
|
||||
- [x] New notifications prepend without rebuilding existing rows
|
||||
- [x] Filter toggles and "View more" still do full re-renders
|
||||
- [x] **1.3** Fix notification `document.click` listener leak
|
||||
- [x] Remove per-row `document.addEventListener('click', ...)`
|
||||
- [x] Register single delegated document-level click listener in `main()`
|
||||
- [x] Added `notif-menu-panel` class for panel discovery
|
||||
- [x] Menu panels still close when clicking outside
|
||||
- [x] **1.4** Cap `posts[]` and `postIds` with rolling eviction
|
||||
- [x] Add `MAX_STORED_POSTS = 500` constant
|
||||
- [x] Evict oldest posts when array exceeds cap (+50 batch threshold)
|
||||
- [x] Rebuild `postIds` and `renderedPostIds` after eviction
|
||||
- [x] Skip pruning in post.html event mode
|
||||
- [x] **1.5** Cap `notificationsById` / `unreadNotificationsById` with periodic pruning
|
||||
- [x] Add `MAX_STORED_NOTIFICATIONS = 500`
|
||||
- [x] Add `pruneNotificationMaps()` function
|
||||
- [x] Call prune on overflow, after cache/relay hydration
|
||||
- [x] LRU-cap `postImageCache` to 200 entries
|
||||
|
||||
### Phase 2: P1 — Subscription Hygiene & Polling Reduction
|
||||
|
||||
- [ ] **2.1** Filter `ndkEvent` by `subId` on the page side
|
||||
- [ ] Verify `subId` is in `event.detail` from init-ndk.mjs
|
||||
- [ ] Track active subscription IDs per page
|
||||
- [ ] Ignore events from foreign subscriptions
|
||||
- [ ] **2.2** Close old live subscriptions on follow-list change
|
||||
- [ ] Store live feed subscription handle
|
||||
- [ ] Close previous subscription before creating new one
|
||||
- [ ] **2.3** Increase footer polling to 5 seconds
|
||||
- [ ] index.html: 1s → 5s
|
||||
- [ ] feed.html: 1s → 5s
|
||||
- [ ] notifications.html: 1s → 5s
|
||||
- [ ] relays.html: 1s → 5s
|
||||
- [ ] **2.4** Diff-update relay table instead of full rebuild
|
||||
- [ ] Split `createRelayTable` into build + update
|
||||
- [ ] In-place update of status icons, counts, checkboxes
|
||||
- [ ] Preserve add-relay input focus across refreshes
|
||||
- [ ] **2.5** Throttle `relayActivity` broadcasts in the worker
|
||||
- [ ] Add batched broadcast with 500ms timer
|
||||
- [ ] Coalesce per-event broadcasts into per-batch
|
||||
- [ ] Stats counts remain accurate
|
||||
|
||||
### Phase 3: P2 — Worker Cleanup & Cache Eviction
|
||||
|
||||
- [ ] **3.1** Prune `connectedPorts` on tab close
|
||||
- [ ] Add `port.onclose` handler in `onconnect`
|
||||
- [ ] Clear `activeSigningPort` if it was the closed port
|
||||
- [ ] **3.2** Evict `inlineComposerInstances` on re-render
|
||||
- [ ] Clear Map after `innerHTML = ''` in full rebuild path
|
||||
- [ ] **3.3** Prune temporary relay Maps after broadcast publish
|
||||
- [ ] Remove non-user relay entries from `relayActivityStats`
|
||||
- [ ] Clear `relayConnectAttemptStartedAt`
|
||||
- [ ] Preserve `relayLastDisconnectMeta` for reconnect detection
|
||||
- [ ] **3.4** Periodic reconciliation of `lightwalletTokenEvents`
|
||||
- [ ] Add 5-minute interval timer
|
||||
- [ ] Remove deleted entries from `lightwalletTokenEvents`
|
||||
- [ ] Trim `lightwalletDeletedIds` to 500
|
||||
- [ ] Clear timer in `handleDisconnect`
|
||||
- [ ] **3.5** Clear health-check and prune intervals on disconnect
|
||||
- [ ] Store health-check interval ID
|
||||
- [ ] Clear both intervals in `handleDisconnect`
|
||||
|
||||
### Phase 4: P3 — Minor Leaks & Polish
|
||||
|
||||
- [ ] **4.1** Clear `refreshTimes` interval on notifications logout
|
||||
- [ ] **4.2** Remove duplicate `message` listener in relays.html
|
||||
- [ ] **4.3** Add `beforeunload` subscription cleanup on all pages
|
||||
187
plans/music-greyscale-to-gruuv-migration.md
Normal file
187
plans/music-greyscale-to-gruuv-migration.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Music: Greyscale → Gruuv API Migration Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Fully replace the Greyscale (Tidal-proxy) music backend used by [`www/music.html`](../www/music.html) with the **Gruuv** Nostr-native protocol. This includes search, browse, streaming, playlists, and a new track upload capability. Greyscale code is removed once the gruuv path is verified.
|
||||
|
||||
A backup of the original page is preserved as [`www/music-greyscale.html`](../www/music-greyscale.html).
|
||||
|
||||
---
|
||||
|
||||
## Background: Why this is an architecture shift, not a swap
|
||||
|
||||
The two backends are fundamentally different in nature.
|
||||
|
||||
| Aspect | Greyscale (current) | Gruuv (target) |
|
||||
|---|---|---|
|
||||
| Catalog source | Tidal proxy REST API over HTTP instance pool | Nostr relays: `kind 36787` track events tagged `t=gruuv` |
|
||||
| Search | Server endpoint `/search/?s=` | Relay query + client-side text filter on title/artist/album |
|
||||
| Streaming | DASH/MP3 manifest decoded from instance | Direct Blossom blob URL (the `url` tag) |
|
||||
| Track ID | Numeric Tidal ID | Addressable coordinate `36787:pubkey:dTag` |
|
||||
| Album | Rich `/album/?id=` endpoint | Derived by grouping track events on the `album` tag |
|
||||
| Artist | Rich `/artist/?id=` endpoint | Derived by grouping track events on the `artist` tag |
|
||||
| Cover art | `resources.tidal.com/images/...` | The `image` tag on the track event |
|
||||
| Playlists | `kind 30004`, `i` tags `tidal:track:ID` | `kind 34139`, `a` tags `36787:pubkey:dTag`, `t=gruuv` |
|
||||
| Upload | N/A (read-only Tidal) | Blossom `PUT /upload` + publish `kind 36787` |
|
||||
|
||||
### What already exists in the project (reused, not rebuilt)
|
||||
|
||||
- [`www/js/init-ndk.mjs`](../www/js/init-ndk.mjs) — provides `subscribe()`, `publishEvent()`, `publishRawEvent()`, `getPubkey()`. The page already wires NDK, relay status UI, and the `ndkEvent` window-event stream.
|
||||
- [`www/js/blossom-api.mjs`](../www/js/blossom-api.mjs) — provides `calculateSHA256()` and `createBlossomAuth()`, which already builds the **`kind 24242`** authorization event that Gruuv's upload spec requires (see [`gruuv/README.md`](../gruuv/README.md)).
|
||||
- [`www/js/blossom-ui.mjs`](../www/js/blossom-ui.mjs) — provides `getBlossomServers()` for the active Blossom target.
|
||||
- [`SimplePlayer`](../www/js/greyscale-player.mjs) — its direct-URL playback path already handles plain audio URLs (Blossom URLs are plain `https`), so playback needs no DASH logic for gruuv.
|
||||
|
||||
### Gruuv protocol reference (from `gruuv/`)
|
||||
|
||||
`kind 36787` track event tags (per [`gruuv/upload_gruuv.sh`](../gruuv/upload_gruuv.sh) and [`gruuv/README.md`](../gruuv/README.md)):
|
||||
|
||||
```
|
||||
d stable track id / dTag
|
||||
url blossom file url
|
||||
title song title
|
||||
artist artist name
|
||||
album album name
|
||||
duration seconds (string)
|
||||
size bytes (string)
|
||||
alt "Song: <title> - <artist>"
|
||||
format mp3 | flac | ...
|
||||
m audio mime (audio/mpeg, audio/flac, ...)
|
||||
x sha256 hex
|
||||
t music
|
||||
t gruuv
|
||||
image (optional) cover art url
|
||||
genre (optional) + t=<genre-lowercase>
|
||||
released (optional) date-like
|
||||
track_number (optional) plain numeric string
|
||||
disc_number (optional, omit when disc 1)
|
||||
```
|
||||
|
||||
`kind 34139` playlist event tags:
|
||||
|
||||
```
|
||||
d playlist id / dTag
|
||||
title
|
||||
description
|
||||
alt "Playlist: <title>"
|
||||
t gruuv
|
||||
image (optional)
|
||||
a 36787:<pubkey>:<dTag> (repeated, one per track)
|
||||
```
|
||||
|
||||
Default relays observed: `wss://relay.primal.net`, `wss://nos.lol`, `wss://relay.damus.io`. Default Blossom: `https://blossom.primal.net`. (The page's own NDK relay config / Blossom server selection should be used in practice.)
|
||||
|
||||
---
|
||||
|
||||
## Design strategy: drop-in module replacement
|
||||
|
||||
To keep [`www/music.html`](../www/music.html) changes minimal and low-risk, the new gruuv modules will expose the **same method names and return shapes** as the current greyscale modules. Then the migration in `music.html` is mostly an import swap plus targeted fixes for the few places where the data model genuinely differs (IDs, derived albums/artists, upload UI).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph page [music.html UI - mostly unchanged]
|
||||
Search[Search box]
|
||||
Browse[Album / Artist views]
|
||||
Player[SimplePlayer audio]
|
||||
Playlists[Playlist panel]
|
||||
Upload[NEW Upload panel]
|
||||
end
|
||||
|
||||
subgraph mods [New gruuv modules]
|
||||
API[gruuv-api.mjs]
|
||||
PL[gruuv-playlists.mjs]
|
||||
UP[gruuv-upload.mjs]
|
||||
end
|
||||
|
||||
subgraph infra [Existing project infra]
|
||||
NDK[init-ndk.mjs subscribe publishEvent]
|
||||
BLOB[blossom-api.mjs sha256 kind 24242 auth]
|
||||
BUI[blossom-ui.mjs getBlossomServers]
|
||||
end
|
||||
|
||||
Search --> API
|
||||
Browse --> API
|
||||
Player --> API
|
||||
Playlists --> PL
|
||||
Upload --> UP
|
||||
API --> NDK
|
||||
PL --> NDK
|
||||
UP --> BLOB
|
||||
UP --> BUI
|
||||
UP --> NDK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data model mapping (gruuv track event → music.html track shape)
|
||||
|
||||
The UI expects the normalized shape produced by [`GreyscaleAPI.prepareTrack()`](../www/js/greyscale-api.mjs:186):
|
||||
`{ id, title, artist, duration, cover, albumTitle, raw }`.
|
||||
|
||||
Mapping from a `kind 36787` event:
|
||||
|
||||
| UI field | Source |
|
||||
|---|---|
|
||||
| `id` | addressable coordinate `36787:<pubkey>:<d-tag>` (fallback to event id) |
|
||||
| `title` | `title` tag |
|
||||
| `artist` | `artist` tag (fallback "Unknown artist") |
|
||||
| `duration` | `duration` tag parsed to number |
|
||||
| `cover` | `image` tag (no Tidal URL transform) |
|
||||
| `albumTitle` | `album` tag |
|
||||
| `streamUrl` | `url` tag (used by `getTrackStream`) |
|
||||
| `raw` | full event for later reference |
|
||||
|
||||
`getCoverUrl()` / `getArtistPictureUrl()` become pass-through helpers (return the URL as-is) so existing render code keeps working.
|
||||
|
||||
---
|
||||
|
||||
## Implementation steps
|
||||
|
||||
### Phase 0 — Safety
|
||||
1. Confirm [`www/music-greyscale.html`](../www/music-greyscale.html) is a faithful backup of the original greyscale page so it can be referenced/restored.
|
||||
|
||||
### Phase 1 — Read path (search, browse, stream)
|
||||
2. Create [`www/js/gruuv-api.mjs`](../www/js/gruuv-api.mjs) exposing the same surface as `GreyscaleAPI`:
|
||||
`searchTracks`, `searchAlbums`, `searchArtists`, `getAlbum`, `getArtist`, `getTrackStream`, `getCoverUrl`, `getArtistPictureUrl`, `prepareTrack`.
|
||||
3. Implement search by subscribing to `kind 36787` with `#t: ['gruuv']`, collecting events until EOSE (with a timeout), then client-side filtering on title/artist/album. Provide a small in-memory cache of fetched track events to back album/artist derivation.
|
||||
4. Implement `getAlbum` / `getArtist` by grouping the cached/fetched track events on the `album` and `artist` tags respectively (gruuv has no album/artist DB). `getAlbum(id)` returns `{ album, tracks }`; `getArtist(id)` returns `{ ...artist, albums, eps, tracks }` to match existing render expectations.
|
||||
5. Implement `getTrackStream` to return `{ streamUrl: <url tag>, isDash: false }`. Cover/artwork helpers return the `image` URL directly.
|
||||
|
||||
### Phase 2 — Playlists
|
||||
6. Create [`www/js/gruuv-playlists.mjs`](../www/js/gruuv-playlists.mjs) mirroring the exports of [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs):
|
||||
`PLAYLIST_KIND` (now `34139`), `buildPlaylistEvent`, `parsePlaylistEvent`, `isMusicPlaylistEvent`, `createPlaylistIdentifier`, `playlistEventAddress`, and any helper the page imports.
|
||||
- Track references use `a` tags of form `36787:<pubkey>:<dTag>` instead of `i`/`tidal:track:` tags.
|
||||
- Topic tag becomes `t=gruuv` (keep `t=music` for discoverability if useful).
|
||||
- Keep a content JSON cache of track metadata (title/artist/cover/duration) like the greyscale version so playlists render before track events resolve.
|
||||
|
||||
### Phase 3 — Upload (new capability)
|
||||
7. Create [`www/js/gruuv-upload.mjs`](../www/js/gruuv-upload.mjs):
|
||||
- `calculateSHA256()` (reuse from blossom-api).
|
||||
- Build `kind 24242` auth via `createBlossomAuth('upload', sha256, 'Upload <filename>')`.
|
||||
- `PUT <activeBlossomServer>/upload` with the `Authorization: Nostr <base64>` header; fall back to `<server>/<sha256>` for the URL if the response lacks one.
|
||||
- Optionally read duration via an `<audio>`/`AudioContext` decode client-side; allow manual title/artist/album entry.
|
||||
- Publish `kind 36787` via `publishEvent()` with the full tag set (including `t=music`, `t=gruuv`, `x`, `m`, `format`, `size`, `alt`).
|
||||
|
||||
### Phase 4 — Wire into music.html
|
||||
8. Swap imports: replace [`GreyscaleAPI`](../www/music.html:1371), the greyscale playlists import block ([lines ~1375–1380](../www/music.html:1375)) with gruuv modules. Keep `SimplePlayer` (it works with direct URLs); the DASH branch simply goes unused.
|
||||
9. Update search handlers ([~3117](../www/music.html:3117), combined search [~4516](../www/music.html:4516)) and detail loaders ([~3837](../www/music.html:3837)–[~3963](../www/music.html:3963)) for the gruuv id format and the grouped album/artist results.
|
||||
10. Update playlist subscription filter and event handling ([`subscribeAllPlaylists`](../www/music.html:3288), [`handleMusicNdkEvent`](../www/music.html:3299), [`hydratePlaylistFromEvent`](../www/music.html:3245)) to `kind 34139` with `t=gruuv` and `a`-tag track refs.
|
||||
11. Add an **Upload Track** UI section (file input, metadata fields, progress/status), gated behind authentication (`promptLoginIfNeeded`), wired to `gruuv-upload.mjs`.
|
||||
12. Update the download ([`fetchDownloadBlobForTrack`](../www/music.html:4342)) and share flows to use the direct Blossom URL instead of Tidal stream manifests.
|
||||
|
||||
### Phase 5 — Cleanup & verification
|
||||
13. Remove greyscale modules ([`greyscale-api.mjs`](../www/js/greyscale-api.mjs), [`greyscale-playlists.mjs`](../www/js/greyscale-playlists.mjs), and [`greyscale-player.mjs`](../www/js/greyscale-player.mjs) if no longer referenced) and any greyscale-only code paths — only after the gruuv path is verified. (Player may be retained/renamed if still used for playback.)
|
||||
14. Run the build inline-check ([`build/.music-inline-check.mjs`](../build/.music-inline-check.mjs)) and do a relay smoke test (cross-check against [`gruuv/list_gruuv_tracks.sh`](../gruuv/list_gruuv_tracks.sh) output). Verify an upload round-trips: publish `36787`, then it appears in search.
|
||||
15. Update docs: [`docs/music.md`](../docs/music.md) and [`docs/MUSIC_URL_STRUCTURE.md`](../docs/MUSIC_URL_STRUCTURE.md) to reflect gruuv kinds/IDs and the new upload feature.
|
||||
|
||||
---
|
||||
|
||||
## Risks & decisions
|
||||
|
||||
- **Relay-native search has no ranking/pagination like a server endpoint.** Mitigation: fetch with a sensible `limit`, cache events, filter client-side, debounce queries. Result completeness depends on relay coverage.
|
||||
- **Albums/artists are derived, not authoritative.** Grouping by free-text `album`/`artist` tags can produce duplicates/variants. Mitigation: normalize/trim, dedupe like the existing `deduplicateAlbums`.
|
||||
- **Track addressing change** (`36787:pubkey:dTag` vs numeric ID) affects deep links and URL structure — covered by the `MUSIC_URL_STRUCTURE.md` update.
|
||||
- **Upload duration/metadata** extraction is best-effort in the browser (no ffprobe); manual fields are the reliable fallback.
|
||||
- **Default relay/Blossom set**: plan uses the page's existing NDK relays and `getBlossomServers()`; gruuv defaults are documented as fallback. Confirm if a specific relay/Blossom set is required.
|
||||
|
||||
## Open question for confirmation
|
||||
- Should the deep-link/URL scheme keep numeric-style IDs (with a translation layer) or move fully to `36787:pubkey:dTag` coordinates?
|
||||
178
plans/wallet-page.md
Normal file
178
plans/wallet-page.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Cashu Wallet Enhancements — Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Enhance the existing `www/cashu.html` page and zap flow, centered on **Cashu as the primary (and only) payment rail**. The core functionality is already implemented — this plan adds UI improvements to show zap capabilities in the feed and let users choose their zap rail.
|
||||
|
||||
### Design philosophy: Cashu-only
|
||||
|
||||
The project uses **Cashu for everything** — no NWC, CLINK, onchain, or Lightning channels to maintain:
|
||||
|
||||
| Payment type | How it works | Already implemented? |
|
||||
|-------------|-------------|---------------------|
|
||||
| **Nutzap (NIP-61)** | Send ecash proofs locked to recipient's p2pk via kind 9321 | ✅ Yes — `walletSendNutzap` |
|
||||
| **Lightning zap (NIP-57)** | Melt Cashu ecash at a mint to pay a BOLT11 invoice | ✅ Yes — `walletPayInvoice` |
|
||||
| **P2P ecash transfer** | Send cashuB token directly to another user | ✅ Yes — `walletSendToken` |
|
||||
| **Lightning deposit** | Pay a BOLT11 invoice, mint issues ecash proofs | ✅ Yes — `walletCreateDeposit` |
|
||||
| **Lightning withdrawal** | Melt ecash to pay an external BOLT11 invoice | ✅ Yes — `walletPayInvoice` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: NIP-60 Compliance Fix (DONE ✅)
|
||||
|
||||
Already completed and deployed (v0.7.19–v0.7.24):
|
||||
- Fixed kind 17375 content format from proprietary `{mints, nutzap}` object to NIP-60 tag-array `[["mint",url],["privkey",hex]]`
|
||||
- Removed non-standard `pubkey` tag from 17375 public tags
|
||||
- Added `parseWalletContent()` backwards-compat shim for reading both formats
|
||||
- Added auto-migration for legacy 17375 events on startup
|
||||
- Added "Republish Wallet (kind 17375)" button with lightweight 17375-only republish
|
||||
- Added `ensureDirectNutzapP2pk()` call in `publishDirectProofs` so privkey is always included
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Zap Capability Indicators in the Feed
|
||||
|
||||
Show zap capability badges on each post's interaction bar so users can see at a glance whether the recipient can receive nutzaps, Lightning zaps, or both.
|
||||
|
||||
### Current state
|
||||
|
||||
The interaction bar in [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) already renders:
|
||||
- ⚡ Zap button with count
|
||||
- 🥜 Nutzap count display (separate element showing total nutzaps received)
|
||||
|
||||
### What to add
|
||||
|
||||
Small capability icons/badges next to the zap button showing what the recipient can receive:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ⚡ 5 🥜 2 🥜✅ ⚡✅ 💬 3 🔄 1 2h ago │
|
||||
│ └─ zap ─┘ └─ nutzap ─┘ └─ capability badges ─┘ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Or a more compact design — color the zap icon differently based on capabilities:
|
||||
|
||||
| Visual | Meaning |
|
||||
|--------|---------|
|
||||
| ⚡ (default color) | Recipient can receive Lightning zaps (has lud16) |
|
||||
| ⚡ + 🥜 (nut badge) | Recipient can also receive nutzaps (has kind 10019 with shared mint) |
|
||||
| ⚡ (dimmed) | Recipient cannot receive any zaps (no lud16, no 10019) |
|
||||
|
||||
### Implementation
|
||||
|
||||
- [ ] **2.1** Create a `resolveZapCapabilities(pubkey)` function that checks:
|
||||
- Does the recipient have a lud16 (Lightning address)? → can receive Lightning zaps
|
||||
- Does the recipient have a kind 10019 (nutzap mint list)? → can receive nutzaps
|
||||
- If yes to 10019, which mints? Do any overlap with the sender's wallet mints?
|
||||
|
||||
- [ ] **2.2** Cache the results per pubkey (the kind 10019 and lud16 don't change often). Use a simple in-memory Map with TTL.
|
||||
|
||||
- [ ] **2.3** Update `createInteractionItem` for the zap type to show capability indicators:
|
||||
- Add a small 🥜 badge on the zap button if the recipient can receive nutzaps
|
||||
- Add a tooltip showing the shared mints
|
||||
- Dim the zap button if the recipient can't receive any zaps
|
||||
|
||||
- [ ] **2.4** Fetch kind 10019 events for post authors as the feed loads. The worker already fetches kind 10019 for nutzap sending — extend this to cache the results for feed display. Use the existing `walletFetchMintList` worker function.
|
||||
|
||||
- [ ] **2.5** Show shared mint info on hover/tooltip: "Can nutzap via mint.minibits.cash/Bitcoin" or "No shared mints — Lightning only" or "No zap capability"
|
||||
|
||||
### Where the code lives
|
||||
|
||||
| File | What to change |
|
||||
|------|---------------|
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add capability badges to zap button rendering |
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()` function (or extend existing `resolveZapSpecForPubkey`) |
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | May need a lightweight "fetch 10019 for display" message (or reuse `walletFetchMintList`) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Zap Rail Selector in the Zap Dialog
|
||||
|
||||
When the user taps the zap button, show which rails are available and let them choose.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[User taps zap button] --> B[Show zap dialog with amount input]
|
||||
B --> C{What rails are available?}
|
||||
C -->|Nutzap + Lightning| D[Show rail toggle: 🥜 Nutzap | ⚡ Lightning]
|
||||
C -->|Lightning only| E[Show Lightning only]
|
||||
C -->|Nutzap only| F[Show Nutzap only]
|
||||
C -->|Neither| G[Show error: recipient cannot receive zaps]
|
||||
D --> H[User selects rail + amount]
|
||||
H --> I{Rail selected}
|
||||
I -->|Nutzap| J[Send NIP-61 nutzap via walletSendNutzap]
|
||||
I -->|Lightning| K[Create LN invoice, melt Cashu via walletPayInvoice]
|
||||
E --> K
|
||||
F --> J
|
||||
```
|
||||
|
||||
- [ ] **3.1** Update `promptZapDetails` in `zaps.mjs` to accept and display rail options:
|
||||
- If nutzap is available (recipient has 10019 with shared mint) → show "🥜 Nutzap" option
|
||||
- If Lightning is available (recipient has lud16) → show "⚡ Lightning" option
|
||||
- Let the user pick which rail to use via a toggle/selector
|
||||
|
||||
- [ ] **3.2** Show balance info in the dialog:
|
||||
- "Cashu balance: 261 sats"
|
||||
- If amount > balance → show warning "Insufficient balance"
|
||||
|
||||
- [ ] **3.3** Default rail selection:
|
||||
- If nutzap is available and amount < 1000 sats → default to nutzap (lower fees)
|
||||
- If nutzap is available and amount ≥ 1000 sats → default to Lightning (more reliable for larger amounts)
|
||||
- If nutzap is not available → default to Lightning
|
||||
|
||||
- [ ] **3.4** Pass the selected rail back to `handleZapClick` in `post-interactions.mjs` so it uses the right send function.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: cashu.html UI Polish
|
||||
|
||||
Minor UI improvements to the existing cashu.html page:
|
||||
|
||||
- [ ] **4.1** Consider renaming the page title from "CASHU" to "WALLET" since it's the project's unified wallet.
|
||||
|
||||
- [ ] **4.2** Update the sidenav entry in `index.html` from "CASHU" to "WALLET" (keeping the same `cashu.html` URL).
|
||||
|
||||
- [ ] **4.3** Add a "Zap via Lightning" info note in the Withdraw panel explaining that this melts ecash to pay a Lightning invoice — same mechanism used for Lightning zaps.
|
||||
|
||||
---
|
||||
|
||||
## NIP Compliance Reference
|
||||
|
||||
| Rail | NIP | Kind(s) | Status |
|
||||
|------|-----|---------|--------|
|
||||
| Cashu Wallet | NIP-60 | 17375, 7375, 7376, 375 | ✅ Fixed and working |
|
||||
| Cashu Nutzap | NIP-61 | 10019, 9321 | ✅ Working |
|
||||
| Lightning Zap | NIP-57 | 9734, 9735 | ✅ Working (via Cashu melt) |
|
||||
|
||||
---
|
||||
|
||||
## Key Files
|
||||
|
||||
### Files to modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| [`www/js/post-interactions.mjs`](www/js/post-interactions.mjs) | Add zap capability badges to interaction bar, pass selected rail to zap handler |
|
||||
| [`www/js/zaps.mjs`](www/js/zaps.mjs) | Add `resolveZapCapabilities()`, update `promptZapDetails` with rail selector |
|
||||
| [`www/cashu.html`](www/cashu.html) | Minor UI polish (title, info notes) |
|
||||
| [`www/index.html`](www/index.html) | Sidenav label: "CASHU" → "WALLET" |
|
||||
|
||||
### Files reused as-is
|
||||
|
||||
| File | Why no changes needed |
|
||||
|------|----------------------|
|
||||
| [`www/js/cashu-wallet.mjs`](www/js/cashu-wallet.mjs) | Controller already wraps all worker functions |
|
||||
| [`www/ndk-worker.js`](www/ndk-worker.js) | All wallet functions already exist (payInvoice, sendNutzap, fetchMintList) |
|
||||
| [`www/js/init-ndk.mjs`](www/js/init-ndk.mjs) | All wallet exports already exist |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** (DONE) — NIP-60 compliance fix
|
||||
2. **Phase 2** (zap capability indicators) — show what the recipient can receive in the feed
|
||||
3. **Phase 3** (rail selector) — let users choose nutzap vs Lightning melt in the zap dialog
|
||||
4. **Phase 4** (UI polish) — minor label/info changes
|
||||
|
||||
No new files needed. No new infrastructure. Just UI improvements on top of the existing working zap flow.
|
||||
2100
tests/broadcast-relay-results.txt
Normal file
2100
tests/broadcast-relay-results.txt
Normal file
File diff suppressed because it is too large
Load Diff
95
tests/broadcast-relay-test.sh
Executable file
95
tests/broadcast-relay-test.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# tests/broadcast-relay-test.sh
|
||||
#
|
||||
# Tests publishing a kind 1 note to each relay in tests/relays.json
|
||||
# using nak, one relay at a time. Reports success/failure for each.
|
||||
#
|
||||
# Usage:
|
||||
# ./tests/broadcast-relay-test.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - nak (https://github.com/fiatjaf/nak) installed and in PATH
|
||||
# - tests/test-key.txt containing a hex private key
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
RELAYS_FILE="$SCRIPT_DIR/relays.json"
|
||||
RESULTS_FILE="$SCRIPT_DIR/broadcast-relay-results.txt"
|
||||
KEY_FILE="$SCRIPT_DIR/test-key.txt"
|
||||
|
||||
# Check for nak
|
||||
if ! command -v nak &>/dev/null; then
|
||||
echo "ERROR: nak is not installed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for key file
|
||||
if [ ! -f "$KEY_FILE" ]; then
|
||||
echo "ERROR: $KEY_FILE not found. Generate one with: nak key generate > $KEY_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SEC=$(cat "$KEY_FILE" | tr -d '[:space:]')
|
||||
|
||||
# Extract relay URLs from the JSON file
|
||||
echo "Extracting relay URLs from $RELAYS_FILE..."
|
||||
mapfile -t RELAYS < <(python3 -c "
|
||||
import json
|
||||
with open('$RELAYS_FILE') as f:
|
||||
data = json.load(f)
|
||||
for tag in data:
|
||||
if isinstance(tag, list) and len(tag) >= 2 and tag[0] == 'r':
|
||||
print(tag[1])
|
||||
")
|
||||
|
||||
TOTAL=${#RELAYS[@]}
|
||||
echo "Found $TOTAL relay URLs"
|
||||
echo ""
|
||||
|
||||
CONTENT="Broadcast relay test $(date -u +%Y-%m-%dT%H:%M:%SZ) — testing relay reachability via nak"
|
||||
echo "Test content: $CONTENT"
|
||||
echo ""
|
||||
|
||||
# Initialize results file
|
||||
{
|
||||
echo "# Broadcast Relay Test Results"
|
||||
echo "# Date: $(date -u)"
|
||||
echo "# Total relays: $TOTAL"
|
||||
echo "# Test content: $CONTENT"
|
||||
echo ""
|
||||
} > "$RESULTS_FILE"
|
||||
|
||||
SUCCESS=0
|
||||
FAILED=0
|
||||
COUNT=0
|
||||
|
||||
for RELAY in "${RELAYS[@]}"; do
|
||||
COUNT=$((COUNT + 1))
|
||||
[ -z "$RELAY" ] && continue
|
||||
|
||||
printf "[%d/%d] %s ... " "$COUNT" "$TOTAL" "$RELAY"
|
||||
|
||||
RESULT=$(timeout 15 nak event --sec "$SEC" -c "$CONTENT" "$RELAY" 2>&1) || RESULT="FAILED: exit code $?"
|
||||
|
||||
if echo "$RESULT" | grep -qi "success"; then
|
||||
echo "OK"
|
||||
echo "OK: $RELAY" >> "$RESULTS_FILE"
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
elif echo "$RESULT" | grep -qi "error\|failed\|refused\|timeout\|reject\|blocked"; then
|
||||
echo "FAIL"
|
||||
echo "FAIL: $RELAY — $RESULT" >> "$RESULTS_FILE"
|
||||
FAILED=$((FAILED + 1))
|
||||
else
|
||||
echo "UNKNOWN"
|
||||
echo "UNKNOWN: $RELAY — $RESULT" >> "$RESULTS_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "Total: $TOTAL"
|
||||
echo "Success: $SUCCESS"
|
||||
echo "Failed: $FAILED"
|
||||
echo ""
|
||||
echo "Full results: $RESULTS_FILE"
|
||||
660
tests/broadcast-test-output.txt
Normal file
660
tests/broadcast-test-output.txt
Normal file
@@ -0,0 +1,660 @@
|
||||
Extracting relay URLs from /home/user/lt/client/tests/relays.json...
|
||||
Found 648 relay URLs
|
||||
|
||||
Test content: Broadcast relay test 2026-06-30T18:29:37Z — testing relay reachability via nak
|
||||
|
||||
[1/648] wss://nos.lol/ ... OK
|
||||
[2/648] wss://relay.primal.net/ ... OK
|
||||
[3/648] wss://nostr.wine/ ... FAIL
|
||||
[4/648] wss://relay.snort.social/ ... OK
|
||||
[5/648] wss://eden.nostr.land/ ... FAIL
|
||||
[6/648] wss://relay.nostr.band/ ... FAIL
|
||||
[7/648] wss://nostr.bitcoiner.social/ ... OK
|
||||
[8/648] wss://purplepag.es/ ... FAIL
|
||||
[9/648] wss://nostr.land/ ... FAIL
|
||||
[10/648] wss://nostr-pub.wellorder.net/ ... OK
|
||||
[11/648] wss://nostr.oxtr.dev/ ... OK
|
||||
[12/648] wss://offchain.pub/ ... OK
|
||||
[13/648] wss://relay.current.fyi/ ... FAIL
|
||||
[14/648] wss://relay.nostr.bg/ ... FAIL
|
||||
[15/648] wss://pyramid.fiatjaf.com/ ... FAIL
|
||||
[16/648] wss://nostr.orangepill.dev/ ... FAIL
|
||||
[17/648] wss://relay.ditto.pub/ ... OK
|
||||
[18/648] wss://nostr.fmt.wiz.biz/ ... FAIL
|
||||
[19/648] wss://brb.io/ ... FAIL
|
||||
[20/648] wss://nostrelites.org/ ... FAIL
|
||||
[21/648] wss://puravida.nostr.land/ ... FAIL
|
||||
[22/648] wss://wot.utxo.one/ ... FAIL
|
||||
[23/648] wss://nostr.mutinywallet.com/ ... FAIL
|
||||
[24/648] wss://relay.nostr.info/ ... FAIL
|
||||
[25/648] wss://nostr.milou.lol/ ... FAIL
|
||||
[26/648] wss://relay.orangepill.dev/ ... FAIL
|
||||
[27/648] wss://relayable.org/ ... FAIL
|
||||
[28/648] wss://filter.nostr.wine/ ... FAIL
|
||||
[29/648] wss://relay.nostrplebs.com/ ... FAIL
|
||||
[30/648] wss://atlas.nostr.land/ ... FAIL
|
||||
[31/648] wss://theforest.nostr1.com/ ... FAIL
|
||||
[32/648] wss://nostr.zebedee.cloud/ ... FAIL
|
||||
[33/648] wss://relay.0xchat.com/ ... OK
|
||||
[34/648] wss://relay.nos.social/ ... OK
|
||||
[35/648] wss://welcome.nostr.wine/ ... FAIL
|
||||
[36/648] wss://bitcoiner.social/ ... FAIL
|
||||
[37/648] wss://relay.noswhere.com/ ... OK
|
||||
[38/648] wss://no.str.cr/ ... OK
|
||||
[39/648] wss://nostr-pub.semisol.dev/ ... FAIL
|
||||
[40/648] wss://nostr.einundzwanzig.space/ ... FAIL
|
||||
[41/648] wss://relay.nostr.com.au/ ... FAIL
|
||||
[42/648] wss://relay.fountain.fm/ ... OK
|
||||
[43/648] wss://relay.nostrati.com/ ... FAIL
|
||||
[44/648] wss://hist.nostr.land/ ... FAIL
|
||||
[45/648] wss://nostr-relay.wlvs.space/ ... FAIL
|
||||
[46/648] wss://nostr.onsats.org/ ... FAIL
|
||||
[47/648] wss://relay.nostrcheck.me/ ... OK
|
||||
[48/648] wss://nostr21.com/ ... FAIL
|
||||
[49/648] wss://purplerelay.com/ ... OK
|
||||
[50/648] wss://relay.bitcoinpark.com/ ... FAIL
|
||||
[51/648] wss://relay.plebstr.com/ ... FAIL
|
||||
[52/648] wss://wot.nostr.party/ ... FAIL
|
||||
[53/648] wss://yabu.me/ ... FAIL
|
||||
[54/648] wss://lightningrelay.com/ ... FAIL
|
||||
[55/648] wss://relay.noderunners.network/ ... FAIL
|
||||
[56/648] wss://spatia-arcana.com/ ... FAIL
|
||||
[57/648] wss://140.f7z.io/ ... FAIL
|
||||
[58/648] wss://nostr-01.yakihonne.com/ ... OK
|
||||
[59/648] wss://nostr.inosta.cc/ ... FAIL
|
||||
[60/648] wss://relay.f7z.io/ ... FAIL
|
||||
[61/648] wss://relay.getalby.com/v1 ... FAIL
|
||||
[62/648] wss://relay.momostr.pink/ ... FAIL
|
||||
[63/648] wss://wot.sovbit.host/ ... FAIL
|
||||
[64/648] wss://aggr.nostr.land/ ... FAIL
|
||||
[65/648] wss://bitcoinmaximalists.online/ ... FAIL
|
||||
[66/648] wss://nostr.plebchain.org/ ... FAIL
|
||||
[67/648] wss://algo.utxo.one/ ... FAIL
|
||||
[68/648] wss://at.nostrworks.com/ ... FAIL
|
||||
[69/648] wss://bevo.nostr1.com/ ... FAIL
|
||||
[70/648] wss://nostr.bitcoinplebs.de/ ... FAIL
|
||||
[71/648] wss://nostr.relayer.se/ ... FAIL
|
||||
[72/648] wss://nostr.rocks/ ... FAIL
|
||||
[73/648] wss://nostr.walletofsatoshi.com/ ... FAIL
|
||||
[74/648] wss://relay.stoner.com/ ... FAIL
|
||||
[75/648] wss://relay.utxo.one/ ... FAIL
|
||||
[76/648] wss://rsslay.nostr.net/ ... FAIL
|
||||
[77/648] ws://umbrel.local:4848/ ... FAIL
|
||||
[78/648] wss://auth.nostr1.com/ ... FAIL
|
||||
[79/648] wss://bitsat.molonlabe.holdings/ ... FAIL
|
||||
[80/648] wss://creatr.nostr.wine/ ... FAIL
|
||||
[81/648] wss://nostr.sethforprivacy.com/ ... FAIL
|
||||
[82/648] wss://nostr.slothy.win/ ... FAIL
|
||||
[83/648] wss://nostr.v0l.io/ ... FAIL
|
||||
[84/648] wss://nostr.zbd.gg/ ... FAIL
|
||||
[85/648] wss://relay.azzamo.net/ ... FAIL
|
||||
[86/648] wss://relay.nostr.net/ ... OK
|
||||
[87/648] wss://relay.nostriches.org/ ... FAIL
|
||||
[88/648] wss://relay.nostrview.com/ ... FAIL
|
||||
[89/648] wss://wot.girino.org/ ... FAIL
|
||||
[90/648] ws://127.0.0.1:4869/ ... FAIL
|
||||
[91/648] wss://e.nos.lol/ ... FAIL
|
||||
[92/648] wss://nostr-02.yakihonne.com/ ... OK
|
||||
[93/648] wss://nostr.cercatrova.me/ ... FAIL
|
||||
[94/648] wss://nostr.chaima.info/ ... OK
|
||||
[95/648] wss://nostr.cypherpunk.today/ ... FAIL
|
||||
[96/648] wss://nostr.portemonero.com/ ... FAIL
|
||||
[97/648] wss://nostr.sandwich.farm/ ... FAIL
|
||||
[98/648] wss://nostr.thesamecat.io/ ... FAIL
|
||||
[99/648] wss://nostrue.com/ ... FAIL
|
||||
[100/648] wss://relay-jp.nostr.wirednet.jp/ ... FAIL
|
||||
[101/648] wss://relay.lnau.net/ ... FAIL
|
||||
[102/648] wss://relay.siamstr.com/ ... FAIL
|
||||
[103/648] wss://relay.wavlake.com/ ... OK
|
||||
[104/648] wss://satsage.xyz/ ... FAIL
|
||||
[105/648] wss://wheat.happytavern.co/ ... OK
|
||||
[106/648] wss://wot.sudocarlos.com/ ... FAIL
|
||||
[107/648] wss://basspistol.org/ ... FAIL
|
||||
[108/648] wss://cellar.nostr.wine/ ... FAIL
|
||||
[109/648] wss://feeds.nostr.band/popular ... FAIL
|
||||
[110/648] wss://freelay.sovbit.host/ ... OK
|
||||
[111/648] wss://inbox.azzamo.net/ ... FAIL
|
||||
[112/648] wss://inbox.nostr.wine/ ... FAIL
|
||||
[113/648] wss://lightning.red/ ... FAIL
|
||||
[114/648] wss://nfdb.noswhere.com/ ... FAIL
|
||||
[115/648] wss://nostr-03.dorafactory.org/ ... FAIL
|
||||
[116/648] wss://nostr-relay.derekross.me/ ... FAIL
|
||||
[117/648] wss://nostr-relay.digitalmob.ro/ ... FAIL
|
||||
[118/648] wss://nostr.pleb.network/ ... FAIL
|
||||
[119/648] wss://nostr.semisol.dev/ ... FAIL
|
||||
[120/648] wss://nostr.thank.eu/ ... FAIL
|
||||
[121/648] wss://nwc.primal.net/ayvjleilmx0al7j2pqt24qed1z7a8s ... FAIL
|
||||
[122/648] wss://orangepiller.org/ ... FAIL
|
||||
[123/648] wss://relay.44billion.net/ ... OK
|
||||
[124/648] wss://relay.divine.video/ ... OK
|
||||
[125/648] wss://relay.mutinywallet.com/ ... FAIL
|
||||
[126/648] wss://relay.nostr.ch/ ... FAIL
|
||||
[127/648] wss://relay.nostr.nu/ ... FAIL
|
||||
[128/648] wss://relay.otherstuff.fyi/ ... FAIL
|
||||
[129/648] wss://relay.routstr.com/ ... OK
|
||||
[130/648] wss://relay.shitforce.one/ ... FAIL
|
||||
[131/648] wss://relay.utxo.one/outbox ... FAIL
|
||||
[132/648] wss://rilo.nostria.app/ ... OK
|
||||
[133/648] wss://search.nos.today/ ... FAIL
|
||||
[134/648] wss://wot.nostr.net/ ... FAIL
|
||||
[135/648] wss://wot.shaving.kiwi/ ... FAIL
|
||||
[136/648] wss://wot.siamstr.com/ ... FAIL
|
||||
[137/648] wss://xmr.usenostr.org/ ... FAIL
|
||||
[138/648] wss://zap.watch/ ... FAIL
|
||||
[139/648] ws://127.0.0.1:8888/ ... FAIL
|
||||
[140/648] wss://adult.18plus.social/ ... FAIL
|
||||
[141/648] wss://anon.computer/ ... FAIL
|
||||
[142/648] wss://bostr.azzamo.net/ ... FAIL
|
||||
[143/648] wss://chorus.mikedilger.com:444/ ... FAIL
|
||||
[144/648] wss://deschooling.us/ ... FAIL
|
||||
[145/648] wss://ditto.pub/relay ... OK
|
||||
[146/648] wss://feeds.nostr.band/typescript ... FAIL
|
||||
[147/648] wss://filter.nostr.wine/npub1s5yq6wadwrxde4lhfs56gn64hwzuhnfa6r9mj476r5s4hkunzgzqrs6q7z ... FAIL
|
||||
[148/648] wss://global-relay.cesc.trade/ ... FAIL
|
||||
[149/648] wss://greensoul.space/ ... FAIL
|
||||
[150/648] wss://haven.calva.dev/ ... FAIL
|
||||
[151/648] wss://indexer.coracle.social/ ... FAIL
|
||||
[152/648] wss://lunchbox.sandwich.farm/ ... FAIL
|
||||
[153/648] wss://me.purplerelay.com/ ... FAIL
|
||||
[154/648] wss://misskey.04.si/ ... FAIL
|
||||
[155/648] wss://news.utxo.one/ ... FAIL
|
||||
[156/648] wss://nostr-02.dorafactory.org/ ... FAIL
|
||||
[157/648] wss://nostr-1.nbo.angani.co/ ... FAIL
|
||||
[158/648] wss://nostr-2.zebedee.cloud/ ... FAIL
|
||||
[159/648] wss://nostr-dev.wellorder.net/ ... OK
|
||||
[160/648] wss://nostr-relay.bitcoin.ninja/ ... FAIL
|
||||
[161/648] wss://nostr-relay.nokotaro.com/ ... FAIL
|
||||
[162/648] wss://nostr.0x7e.xyz/ ... OK
|
||||
[163/648] wss://nostr.88mph.life/ ... OK
|
||||
[164/648] wss://nostr.azte.co/ ... FAIL
|
||||
[165/648] wss://nostr.azzamo.net/ ... OK
|
||||
[166/648] wss://nostr.bitpunk.fm/ ... FAIL
|
||||
[167/648] wss://nostr.data.haus/ ... OK
|
||||
[168/648] wss://nostr.lopp.social/ ... FAIL
|
||||
[169/648] wss://nostr.lorentz.is/ ... FAIL
|
||||
[170/648] wss://nostr.lu.ke/ ... FAIL
|
||||
[171/648] wss://nostr.roundrockbitcoiners.com/ ... FAIL
|
||||
[172/648] wss://nostr.sebastix.dev/ ... FAIL
|
||||
[173/648] wss://nostr.self-determined.de/ ... FAIL
|
||||
[174/648] wss://nostr.sovbit.host/ ... FAIL
|
||||
[175/648] wss://nostr.stakey.net/ ... FAIL
|
||||
[176/648] wss://nostr.tavux.tech/ ... FAIL
|
||||
[177/648] wss://nostr.vulpem.com/ ... OK
|
||||
[178/648] wss://nostr.xmr.rocks/ ... FAIL
|
||||
[179/648] wss://nostr01.sharkshake.net/ ... FAIL
|
||||
[180/648] wss://nostrcheck.me/relay ... OK
|
||||
[181/648] wss://nostrsatva.net/ ... FAIL
|
||||
[182/648] wss://nrelay.c-stellar.net/ ... FAIL
|
||||
[183/648] wss://paid.no.str.cr/ ... FAIL
|
||||
[184/648] wss://public.nostr.swissrouting.com/ ... FAIL
|
||||
[185/648] wss://r.hostr.cc/ ... FAIL
|
||||
[186/648] wss://relay.bitblockboom.com/ ... FAIL
|
||||
[187/648] wss://relay.bitcoindistrict.org/ ... FAIL
|
||||
[188/648] wss://relay.bullishbounty.com/ ... OK
|
||||
[189/648] wss://relay.cashumints.space/ ... FAIL
|
||||
[190/648] wss://relay.coinos.io/ ... OK
|
||||
[191/648] wss://relay.damus.com/ ... FAIL
|
||||
[192/648] wss://relay.dergigi.com/ ... FAIL
|
||||
[193/648] wss://relay.dreamith.to/ ... OK
|
||||
[194/648] wss://relay.exit.pub/ ... FAIL
|
||||
[195/648] wss://relay.gasteazi.net/ ... OK
|
||||
[196/648] wss://relay.geekiam.services/ ... FAIL
|
||||
[197/648] wss://relay.getsafebox.app/ ... OK
|
||||
[198/648] wss://relay.kamp.site/ ... FAIL
|
||||
[199/648] wss://relay.martien.io/ ... FAIL
|
||||
[200/648] wss://relay.nosflare.com/ ... FAIL
|
||||
[201/648] wss://relay.nostr.band/all ... FAIL
|
||||
[202/648] wss://relay.nostr.hu/ ... FAIL
|
||||
[203/648] wss://relay.nostr.pro/ ... FAIL
|
||||
[204/648] wss://relay.nostr.wirednet.jp/ ... OK
|
||||
[205/648] wss://relay.nostrgraph.net/ ... FAIL
|
||||
[206/648] wss://relay.nostrica.com/ ... FAIL
|
||||
[207/648] wss://relay.nostrich.de/ ... FAIL
|
||||
[208/648] wss://relay.nostrss.re/ ... FAIL
|
||||
[209/648] wss://relay.notoshi.win/ ... OK
|
||||
[210/648] wss://relay.nsecbunker.com/ ... FAIL
|
||||
[211/648] wss://relay.s-w.art/ ... FAIL
|
||||
[212/648] wss://relay.sigit.io/ ... OK
|
||||
[213/648] wss://relay.sovbit.host/ ... FAIL
|
||||
[214/648] wss://relay.sovereignengineering.io/ ... FAIL
|
||||
[215/648] wss://relay.vertexlab.io/ ... FAIL
|
||||
[216/648] wss://relay.wellorder.net/ ... OK
|
||||
[217/648] wss://relay.westernbtc.com/ ... FAIL
|
||||
[218/648] wss://relayer.fiatjaf.com/ ... FAIL
|
||||
[219/648] wss://ribo.eu.nostria.app/ ... OK
|
||||
[220/648] wss://ribo.nostria.app/ ... OK
|
||||
[221/648] wss://temp.iris.to/ ... OK
|
||||
[222/648] wss://thebarn.nostr1.com/ ... FAIL
|
||||
[223/648] wss://tigs.nostr1.com/ ... FAIL
|
||||
[224/648] wss://vault.iris.to/ ... OK
|
||||
[225/648] wss://vitor.nostr1.com/ ... FAIL
|
||||
[226/648] wss://xmr.ithurtswhenip.ee/ ... FAIL
|
||||
[227/648] ⬤ wss://nostr-pub.wellorder.net ... FAIL
|
||||
[228/648] coracle ... FAIL
|
||||
[229/648] https://sovbitm2enxfr5ot6qscwy5ermdffbqscy66wirkbsigvcshumyzbbqd.onion/ ... FAIL
|
||||
[230/648] ws://192.168.1.63:4848/ ... FAIL
|
||||
[231/648] ws://2tkf2psfhves3tp2izcchzc5gpiowk4gzkcqls4l46wj5v4b772tyead.onion/ ... FAIL
|
||||
[232/648] ws://bitcoin.anneca.cz:4848/ ... FAIL
|
||||
[233/648] ws://eden.nostr.land/ ... FAIL
|
||||
[234/648] ws://ehsc3vsoqugf5dyzvop2o4ii7nos3yccirirlxmycx4hle5epbxppnad.onion/ ... FAIL
|
||||
[235/648] ws://k6dpciogx4fabnipku6wlce4rjv3ffjhv6gcundcxvxn6poeq2hcn3id.onion/ ... FAIL
|
||||
[236/648] ws://localhost:4869/ ... FAIL
|
||||
[237/648] ws://qwqk4b6royrrgmk4pvoccok4wt5fcvpulwnse5656bcqt3bncokpt3qd.onion/ ... FAIL
|
||||
[238/648] ws://relay.jb55.com/ ... FAIL
|
||||
[239/648] ws://w3xtwz6dc7krqkkm22odcvpdpc5escq7lg266quupdyatxr5gjcm55ad.onion/ ... FAIL
|
||||
[240/648] ws://willem.currycash.net:4848/ ... FAIL
|
||||
[241/648] wss://1.noztr.com/ ... FAIL
|
||||
[242/648] wss://2jsnlhfnelig5acq6iacydmzdbdmg7xwunm4xl6qwbvzacw4lwrjmlyd.onion/ ... FAIL
|
||||
[243/648] wss://64.23.224.231:54711/ ... FAIL
|
||||
[244/648] wss://adfasfasfadsdfasfasf3123412ewfas.xyz/ ... FAIL
|
||||
[245/648] wss://adre.su/ ... FAIL
|
||||
[246/648] wss://ae.purplerelay.com/ ... FAIL
|
||||
[247/648] wss://ae3t4h2.oops.wtf/ ... FAIL
|
||||
[248/648] wss://aegis.utxo.one/ ... FAIL
|
||||
[249/648] wss://alphapanda.pro/ ... FAIL
|
||||
[250/648] wss://art.nostrfreaks.com/ ... FAIL
|
||||
[251/648] wss://asia.azzamo.net/ ... FAIL
|
||||
[252/648] wss://atlas.nostr.land/invoices ... FAIL
|
||||
[253/648] wss://au.relayable.org/ ... FAIL
|
||||
[254/648] wss://bitcoinmajlis.nostr1.com/ ... FAIL
|
||||
[255/648] wss://bitcoinostr.duckdns.org/ ... OK
|
||||
[256/648] wss://bitcoinr6de5lkvx4tpwdmzrdfdpla5sya2afwpcabjup2xpi5dulbad.onion/ ... FAIL
|
||||
[257/648] wss://bitstack.app/ ... FAIL
|
||||
[258/648] wss://catstrr.swarmstr.com/ ... FAIL
|
||||
[259/648] wss://christpill.nostr1.com/ ... FAIL
|
||||
[260/648] wss://ciao.rinbal.de/ ... FAIL
|
||||
[261/648] wss://cyberspace.nostr1.com/ ... OK
|
||||
[262/648] wss://db4efobus2ilttw5gokcwck3okcr476hqkmyqduoyydlo2j7iigehkyd.local/ ... FAIL
|
||||
[263/648] wss://directory.yabu.me/alpha-ember ... FAIL
|
||||
[264/648] wss://echo.websocket.org/ ... FAIL
|
||||
[265/648] wss://eclipse.pub/relay ... FAIL
|
||||
[266/648] wss://ehsc3vsoqugf5dyzvop2o4ii7nos3yccirirlxmycx4hle5epbxppnad.local/ ... FAIL
|
||||
[267/648] wss://elites.nostrati.org/ ... FAIL
|
||||
[268/648] wss://espelho.girino.org/ ... FAIL
|
||||
[269/648] wss://essayist.decentnewsroom.com/ ... FAIL
|
||||
[270/648] wss://eu.nostr.pikachat.org/juliet-alpha-nexus ... FAIL
|
||||
[271/648] wss://eu.rbr.bio/ ... FAIL
|
||||
[272/648] wss://eyes.f7z.io/ ... FAIL
|
||||
[273/648] wss://fabian.nostr1.com/ ... FAIL
|
||||
[274/648] wss://fanfares.nostr1.com/ ... OK
|
||||
[275/648] wss://feeds.nostr.band/omni__ventures ... FAIL
|
||||
[276/648] wss://feeds.nostr.band/pics ... FAIL
|
||||
[277/648] wss://fiatjaf.nostr1.com/ ... FAIL
|
||||
[278/648] wss://filter.nostr.wine/?global=all ... FAIL
|
||||
[279/648] wss://filter.nostr.wine/npub12gu8c6uee3p243gez6cgk76362admlqe72aq3kp2fppjsjwmm7eqj9fle6?broadcast=true ... FAIL
|
||||
[280/648] wss://filter.nostr.wine/npub14yzxejghthz6ghae8gkgjru63vvvwpl6d454wud2hycqpqwnugdqcutuw5?broadcast=true ... FAIL
|
||||
[281/648] wss://filter.nostr.wine/npub153xmex42x4chdf757hp3q6zxagykkek7pdgwuwd074964dkyha9s82ryu8?broadcast=true ... FAIL
|
||||
[282/648] wss://filter.nostr.wine/npub16w4nxxv7kjxx77zsw262v65w27q5udwnzd6ue2xremhvc9clxzaq974vef?broadcast=true ... FAIL
|
||||
[283/648] wss://filter.nostr.wine/npub17xu3rtcu0ftqw03mswa8a2ngz3nsgrs0h0wjv4z942qwvhp8fs3qzcadls?broadcast=true ... FAIL
|
||||
[284/648] wss://filter.nostr.wine/npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424?broadcast=true ... FAIL
|
||||
[285/648] wss://filter.nostr.wine/npub1a6zkqnuwcmjwynuw4u4xyngy9675x8dwgj87z9me4h8mdwmc2a0q8mvhjk?broadcast=true ... FAIL
|
||||
[286/648] wss://filter.nostr.wine/npub1cuyfg04rf9gemn6kk2juzw8anmgxftt9mhk2ucu5a27c0f30zacqggzw8d?broadcast=true ... FAIL
|
||||
[287/648] wss://filter.nostr.wine/npub1du6sgl90wse0cz44fg50a4kg9ea4sgctlxps90ccx58lw8ssgv9qhjyf3c?broadcast=true ... FAIL
|
||||
[288/648] wss://filter.nostr.wine/npub1ngumlqmus6xkrmvvee4yc7swh9h4uk7vpq4ddt7a2jtvkc22y0asrse3pv?broadcast=true ... FAIL
|
||||
[289/648] wss://filter.nostr.wine/npub1nh4z0p2ewjsgln4533q04wzr9sdguwjn58dz9gdd26zet4spqeysktrh05?broadcast=true ... FAIL
|
||||
[290/648] wss://filter.nostr.wine/npub1p4kg8zxukpym3h20erfa3samj00rm2gt4q5wfuyu3tg0x3jg3gesvncxf8 ... FAIL
|
||||
[291/648] wss://filter.nostr.wine/npub1qjgcmlpkeyl8mdkvp4s0xls4ytcux6my606tgfx9xttut907h0zs76lgjw?broadcast=true ... FAIL
|
||||
[292/648] wss://filter.nostr.wine/npub1rr654u0pp3dm0g65dzc0v2eft5frg7gre8u4wwxsvhyyhmc5qths50eeul?broadcast=true ... FAIL
|
||||
[293/648] wss://filter.nostr.wine/npub1rtlqca8r6auyaw5n5h3l5422dm4sry5dzfee4696fqe8s6qgudks7djtfs?broadcast=true ... FAIL
|
||||
[294/648] wss://filter.nostr.wine/npub1sjjz60h6fqqcuxrsyl3thhgpx2z6ylv047tslqar26ga0chp5vgq7404nu?broadcast=true ... FAIL
|
||||
[295/648] wss://filter.nostr.wine/npub1t0nyg64g5vwprva52wlcmt7fkdr07v5dr7s35raq9g0xgc0k4xcsedjgqv?broadcast=true ... FAIL
|
||||
[296/648] wss://filter.nostr.wine/npub1t5wc8h37uhkau9tsw82sjxndq04d3n8p634utpdfvs4tm5xmt2sqgk6dke?broadcast=true ... FAIL
|
||||
[297/648] wss://filter.nostr.wine/npub1xv6axulxcx6mce5mfvfzpsy89r4gee3zuknulm45cqqpmyw7680q5pxea6?broadcast=true ... FAIL
|
||||
[298/648] wss://filter.nostr.wine/npub1yaul8k059377u9lsu67de7y637w4jtgeuwcmh5n7788l6xnlnrgs3tvjmf?broadcast=true ... FAIL
|
||||
[299/648] wss://filter.nostr.wine/npub1zqsu3ys4fragn2a5e3lgv69r4rwwhts2fserll402uzr3qeddxfsffcqrs?broadcast=true ... FAIL
|
||||
[300/648] wss://foton.solarpowe.red/ ... FAIL
|
||||
[301/648] wss://frens.nostr1.com/ ... FAIL
|
||||
[302/648] wss://frens.utxo.one/ ... FAIL
|
||||
[303/648] wss://futarchyhub.com/relay ... FAIL
|
||||
[304/648] wss://galaxy13.nostr1.com/ ... FAIL
|
||||
[305/648] wss://garden.zap.cooking/ ... FAIL
|
||||
[306/648] wss://gleasonator.dev/relay ... OK
|
||||
[307/648] wss://gm.swarmstr.com/ ... FAIL
|
||||
[308/648] wss://grace.ikaros.hzrd149.com/ ... FAIL
|
||||
[309/648] wss://groups.0xchat.com/ ... FAIL
|
||||
[310/648] wss://haven.dergigi.com/ ... FAIL
|
||||
[311/648] wss://haven.downisontheup.ca/ ... FAIL
|
||||
[312/648] wss://haven.slidestr.net/ ... FAIL
|
||||
[313/648] wss://haven.sovereignengineering.io/outbox ... FAIL
|
||||
[314/648] wss://hbr.coracle.social/ ... FAIL
|
||||
[315/648] wss://henhouse.social/relay ... FAIL
|
||||
[316/648] wss://hodlbod.coracle.social/ ... FAIL
|
||||
[317/648] wss://hodlbod.coracle.tools/ ... FAIL
|
||||
[318/648] wss://HodlHarry@nostrcheck.me/ ... OK
|
||||
[319/648] wss://hole.v0l.io/ ... FAIL
|
||||
[320/648] wss://hotrightnow.nostr1.com/ ... FAIL
|
||||
[321/648] wss://https//wot.utxo.one/# ... FAIL
|
||||
[322/648] wss://inbox.relays.land/ ... FAIL
|
||||
[323/648] wss://inner.sebastix.social/ ... FAIL
|
||||
[324/648] wss://iris.to/ ... FAIL
|
||||
[325/648] wss://kmc-nostr.amiunderwater.com/ ... FAIL
|
||||
[326/648] wss://knostr.neutrine.com:8880/ ... FAIL
|
||||
[327/648] wss://knostr.neutrine.com/ ... FAIL
|
||||
[328/648] wss://kr.purplerelay.com/ ... FAIL
|
||||
[329/648] wss://librerelay.aaroniumii.com/ ... FAIL
|
||||
[330/648] wss://ln.weedstr.net/nostrrelay/weedstr ... FAIL
|
||||
[331/648] wss://lnb.bolverker.com/nostrrelay/666 ... FAIL
|
||||
[332/648] wss://lockbox.fiatjaf.com/ ... FAIL
|
||||
[333/648] wss://lv01.tater.ninja/ ... FAIL
|
||||
[334/648] wss://moonboi.nostrfreaks.com/ ... FAIL
|
||||
[335/648] wss://muxstr.northwest.io/ ... FAIL
|
||||
[336/648] wss://nerostr.xmr.rocks/ ... FAIL
|
||||
[337/648] wss://news.nos.social/ ... FAIL
|
||||
[338/648] wss://nfdb.noswhere.com/victor ... FAIL
|
||||
[339/648] wss://nip17.com/ ... FAIL
|
||||
[340/648] wss://njump.me/ ... FAIL
|
||||
[341/648] wss://nos.win/ ... FAIL
|
||||
[342/648] wss://nostr-01.bolt.observer/ ... FAIL
|
||||
[343/648] wss://nostr-paid.h3z.jp/ ... FAIL
|
||||
[344/648] wss://nostr-relay.amethyst.name/ ... FAIL
|
||||
[345/648] wss://nostr-relay.amethyst.name/jade-raven-juliet ... FAIL
|
||||
[346/648] wss://nostr-relay.feddit.social/ ... FAIL
|
||||
[347/648] wss://nostr-relay.lnmarkets.com/ ... FAIL
|
||||
[348/648] wss://nostr-relay.philipcristiano.com/ ... FAIL
|
||||
[349/648] wss://nostr-relay.rawrapp.workers.dev/ ... FAIL
|
||||
[350/648] wss://nostr-relay.schnitzel.world/ ... FAIL
|
||||
[351/648] wss://nostr-relay.untethr.me/ ... FAIL
|
||||
[352/648] wss://nostr-relay.usebitcoin.space/ ... FAIL
|
||||
[353/648] wss://nostr-verified.wellorder.net/ ... OK
|
||||
[354/648] wss://nostr.1sat.org/ ... FAIL
|
||||
[355/648] wss://nostr.256k1.dev/ ... FAIL
|
||||
[356/648] wss://nostr.438b.net/ ... FAIL
|
||||
[357/648] wss://nostr.4rs.nl/ ... OK
|
||||
[358/648] wss://nostr.8777.ch/ ... FAIL
|
||||
[359/648] wss://nostr.actn.io/ ... FAIL
|
||||
[360/648] wss://nostr.app.runonflux.io/ ... OK
|
||||
[361/648] wss://nostr.at/ ... FAIL
|
||||
[362/648] wss://nostr.azuki.blue/ember-ivory-mike ... FAIL
|
||||
[363/648] wss://nostr.azzamo.net/flint-warden ... OK
|
||||
[364/648] wss://nostr.beckmeyer.us/ ... FAIL
|
||||
[365/648] wss://nostr.bitcoin.ninja/ ... FAIL
|
||||
[366/648] wss://nostr.bitcoin.social/ ... FAIL
|
||||
[367/648] wss://nostr.bostonbtc.com/ ... FAIL
|
||||
[368/648] wss://nostr.build/ ... FAIL
|
||||
[369/648] wss://nostr.cizmar.net/ ... FAIL
|
||||
[370/648] wss://nostr.cloud.vinney.xyz/ ... FAIL
|
||||
[371/648] wss://nostr.coinfundit.com/ ... FAIL
|
||||
[372/648] wss://nostr.compile-error.net/ ... FAIL
|
||||
[373/648] wss://nostr.cx.ms/ ... FAIL
|
||||
[374/648] wss://nostr.delo.software/ ... FAIL
|
||||
[375/648] wss://nostr.digitalreformation.info/ ... FAIL
|
||||
[376/648] wss://nostr.dontyou.click/oscar-warden-flint ... FAIL
|
||||
[377/648] wss://nostr.drss.io/ ... FAIL
|
||||
[378/648] wss://nostr.dvdt.dev/ ... FAIL
|
||||
[379/648] wss://nostr.easify.de/ ... FAIL
|
||||
[380/648] wss://nostr.easydns.ca/ ... FAIL
|
||||
[381/648] wss://nostr.extrabits.io/ ... FAIL
|
||||
[382/648] wss://nostr.fediverse.jp/ ... FAIL
|
||||
[383/648] wss://nostr.flamingo-mail.com/ ... FAIL
|
||||
[384/648] wss://nostr.gives.africa/ ... FAIL
|
||||
[385/648] wss://nostr.gromeul.eu/ ... FAIL
|
||||
[386/648] wss://nostr.holybea.com/ ... FAIL
|
||||
[387/648] wss://nostr.huszonegy.world/ ... OK
|
||||
[388/648] wss://nostr.ingwie.me/ ... FAIL
|
||||
[389/648] wss://nostr.jiashanlu.synology.me/ ... FAIL
|
||||
[390/648] wss://nostr.k3tan.com/ ... FAIL
|
||||
[391/648] wss://nostr.koning-degraaf.nl/ ... FAIL
|
||||
[392/648] wss://nostr.kosmos.org/ ... FAIL
|
||||
[393/648] wss://nostr.land/xray-xray-cipher ... FAIL
|
||||
[394/648] wss://nostr.lnproxy.org/ ... FAIL
|
||||
[395/648] wss://nostr.malin.onl/ ... FAIL
|
||||
[396/648] wss://nostr.massmux.com/ ... FAIL
|
||||
[397/648] wss://nostr.middling.mydns.jp/ ... FAIL
|
||||
[398/648] wss://nostr.mineracks.com/ ... OK
|
||||
[399/648] wss://nostr.noones.com/ ... FAIL
|
||||
[400/648] wss://nostr.novacisko.cz/ ... FAIL
|
||||
[401/648] wss://nostr.okaits7534.net/ ... FAIL
|
||||
[402/648] wss://nostr.oldenburg.cool/ ... FAIL
|
||||
[403/648] wss://nostr.openhoofd.nl/ ... OK
|
||||
[404/648] wss://nostr.ownscale.org/ ... FAIL
|
||||
[405/648] wss://nostr.palandi.cloud/ ... FAIL
|
||||
[406/648] wss://nostr.pareto.town/ ... FAIL
|
||||
[407/648] wss://nostr.petrkr.net/willow ... FAIL
|
||||
[408/648] wss://nostr.primz.org/charlie-xenon-bravo ... FAIL
|
||||
[409/648] wss://nostr.radixrat.com/ ... FAIL
|
||||
[410/648] wss://nostr.robosats.org/ ... FAIL
|
||||
[411/648] wss://nostr.sathoarder.com/titan-glyph ... OK
|
||||
[412/648] wss://nostr.sats.coffee/ ... FAIL
|
||||
[413/648] wss://nostr.sats.li/ ... FAIL
|
||||
[414/648] wss://nostr.screaminglife.io/ ... FAIL
|
||||
[415/648] wss://nostr.sectiontwo.org/ ... FAIL
|
||||
[416/648] wss://nostr.sg/ ... FAIL
|
||||
[417/648] wss://nostr.sixteensixtyone.com/ ... FAIL
|
||||
[418/648] wss://nostr.stoner.com/ ... FAIL
|
||||
[419/648] wss://nostr.superfriends.online/titan ... OK
|
||||
[420/648] wss://nostr.supremestack.xyz/ ... FAIL
|
||||
[421/648] wss://nostr.swiss-enigma.ch/ ... FAIL
|
||||
[422/648] wss://nostr.terminus.money/ ... FAIL
|
||||
[423/648] wss://nostr.topeth.info/ ... FAIL
|
||||
[424/648] wss://nostr.uselessshit.co/ ... FAIL
|
||||
[425/648] wss://nostr.yuv.al/ ... FAIL
|
||||
[426/648] wss://nostr.zoel.network/ ... FAIL
|
||||
[427/648] wss://nostr01.counterclockwise.io/ ... FAIL
|
||||
[428/648] wss://nostr1.tunnelsats.com/ ... FAIL
|
||||
[429/648] wss://nostr1676319567170.app.runonflux.io/ ... FAIL
|
||||
[430/648] wss://nostr21.com/raven ... FAIL
|
||||
[431/648] wss://nostrcheck.tnsor.network/ ... FAIL
|
||||
[432/648] wss://nostrelay.circum.space/ ... OK
|
||||
[433/648] wss://nostrelay.unchainedhome.com/ ... OK
|
||||
[434/648] wss://nostrex.fly.dev/ ... FAIL
|
||||
[435/648] wss://nostrrelay.com/ ... FAIL
|
||||
[436/648] wss://nostrrelay.taylorperron.com/ ... FAIL
|
||||
[437/648] wss://notify.damus.io/ ... FAIL
|
||||
[438/648] wss://novoa.nagoya/ ... FAIL
|
||||
[439/648] wss://now.lol/ ... FAIL
|
||||
[440/648] wss://npub1spxdug4m3y24hpx5crm0el4zhkk0wafs8kp6m0xu0wecygqej2xqq8gyhx.fips.network/ ... FAIL
|
||||
[441/648] wss://nsite.run/ ... FAIL
|
||||
[442/648] wss://nsrelay.assilvestrar.club/ ... FAIL
|
||||
[443/648] wss://nstr.utn.lol/ ... FAIL
|
||||
[444/648] wss://nwc.primal.net/c4ib8h2rs62bkmlk4wu7jbko9ugqbn ... FAIL
|
||||
[445/648] wss://onchain.pub/ ... FAIL
|
||||
[446/648] wss://paid-relay.nostr.blockhenge.com/ ... FAIL
|
||||
[447/648] wss://paid.nostrified.org/ ... FAIL
|
||||
[448/648] wss://pleb.cloud/ ... FAIL
|
||||
[449/648] wss://primal-cache.mutinywallet.com/v1 ... FAIL
|
||||
[450/648] wss://prl.plus/ ... OK
|
||||
[451/648] wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87 ... FAIL
|
||||
[452/648] wss://proxy.nostr-relay.app/8c5723f2601334234e1922d2e842d6bbf209283b07120b3f1d38660915f13793 ... FAIL
|
||||
[453/648] wss://public.crostr.com/beacon-umbra ... OK
|
||||
[454/648] wss://public.relaying.io/ ... FAIL
|
||||
[455/648] wss://questions.swarmstr.com/ ... FAIL
|
||||
[456/648] wss://r.f7z.io/ ... FAIL
|
||||
[457/648] wss://r.kojira.io/ ... FAIL
|
||||
[458/648] wss://r314y.0xd43m0n.xyz/ ... FAIL
|
||||
[459/648] wss://ragnar-relay.com/ ... FAIL
|
||||
[460/648] wss://raley.azzamo.net/ ... FAIL
|
||||
[461/648] wss://realy.damus.io/ ... FAIL
|
||||
[462/648] wss://realy.nostr.bg/ ... FAIL
|
||||
[463/648] wss://relais.noswhere.com/ ... FAIL
|
||||
[464/648] wss://relay-1.arsip.my.id/ ... FAIL
|
||||
[465/648] wss://relay-rpi.edufeed.org/ ... OK
|
||||
[466/648] wss://relay-testnet.k8s.layer3.news/xenon-anchor ... OK
|
||||
[467/648] wss://relay.2020117.xyz/oscar-lima ... FAIL
|
||||
[468/648] wss://relay.21mil.me/ ... FAIL
|
||||
[469/648] wss://relay.8333.space/ ... FAIL
|
||||
[470/648] wss://relay.angor.io/ ... OK
|
||||
[471/648] wss://relay.apps.sebdev.io/ ... FAIL
|
||||
[472/648] wss://relay.artx.market/ ... OK
|
||||
[473/648] wss://relay.austrich.net/ ... FAIL
|
||||
[474/648] wss://relay.benthecarman.com/ ... FAIL
|
||||
[475/648] wss://relay.bitesize-media.com/ ... FAIL
|
||||
[476/648] wss://relay.bitmapstr.io/ ... FAIL
|
||||
[477/648] wss://relay.bleskop.com/ ... FAIL
|
||||
[478/648] wss://relay.braydon.com/ ... FAIL
|
||||
[479/648] wss://relay.carlos-cdb.top/vertex-whiskey ... OK
|
||||
[480/648] wss://relay.causes.com/ ... FAIL
|
||||
[481/648] wss://relay.codl.co/ ... FAIL
|
||||
[482/648] wss://relay.corpum.com/ ... OK
|
||||
[483/648] wss://relay.cryptocculture.com/ ... FAIL
|
||||
[484/648] wss://relay.deezy.io/ ... FAIL
|
||||
[485/648] wss://relay.despera.space/ ... FAIL
|
||||
[486/648] wss://relay.devstr.org/ ... FAIL
|
||||
[487/648] wss://relay.digitalezukunft.cyou/ ... FAIL
|
||||
[488/648] wss://relay.disobey.dev/ ... FAIL
|
||||
[489/648] wss://relay.dwadziesciajeden.pl/ ... OK
|
||||
[490/648] wss://relay.getalby.com/ ... FAIL
|
||||
[491/648] wss://relay.geyser.fund/ ... FAIL
|
||||
[492/648] wss://relay.gobrrr.me/ ... FAIL
|
||||
[493/648] wss://relay.goodmorningbitcoin.com/ ... FAIL
|
||||
[494/648] wss://relay.gulugulu.moe/ ... OK
|
||||
[495/648] wss://relay.hangar.ninja/ ... FAIL
|
||||
[496/648] wss://relay.hash.stream/ ... FAIL
|
||||
[497/648] wss://relay.highlighter.com/ ... FAIL
|
||||
[498/648] wss://relay.hodlboard.org/ ... FAIL
|
||||
[499/648] wss://relay.hunos.hu/ ... FAIL
|
||||
[500/648] wss://relay.iefan.net/ ... FAIL
|
||||
[501/648] wss://relay.ingwie.me/ ... FAIL
|
||||
[502/648] wss://relay.islandbitcoin.com/echo-anchor-cipher ... OK
|
||||
[503/648] wss://relay.jeffg.fyi/ ... FAIL
|
||||
[504/648] wss://relay.jerseyplebs.com/ ... FAIL
|
||||
[505/648] wss://relay.kisiel.net.pl/ ... FAIL
|
||||
[506/648] wss://relay.lab.rytswd.com/karma-titan-lantern ... OK
|
||||
[507/648] wss://relay.lacompagniemaximus.com/romeo-yonder ... FAIL
|
||||
[508/648] wss://relay.layer.systems/ ... FAIL
|
||||
[509/648] wss://relay.lexingtonbitcoin.org/ ... FAIL
|
||||
[510/648] wss://relay.lightning.pub/yankee ... OK
|
||||
[511/648] wss://relay.livefreebtc.dev/ ... FAIL
|
||||
[512/648] wss://relay.magiccity.live/ ... FAIL
|
||||
[513/648] wss://relay.mccormick.cx/quartz ... OK
|
||||
[514/648] wss://relay.mess.ch/ ... FAIL
|
||||
[515/648] wss://relay.minds.com/nostr/v1/ws ... FAIL
|
||||
[516/648] wss://relay.mostro.network/ ... OK
|
||||
[517/648] wss://relay.nextblock.city/ ... FAIL
|
||||
[518/648] wss://relay.nimo.cash/ ... FAIL
|
||||
[519/648] wss://relay.nodana.app/ ... FAIL
|
||||
[520/648] wss://relay.nomus.io/ ... OK
|
||||
[521/648] wss://relay.nosotros.app/ ... FAIL
|
||||
[522/648] wss://relay.nostpy.lol/ ... FAIL
|
||||
[523/648] wss://relay.nostr.ai/ ... FAIL
|
||||
[524/648] wss://relay.nostr.band/trusted ... FAIL
|
||||
[525/648] wss://relay.nostr.blockhenge.com/ ... OK
|
||||
[526/648] wss://relay.nostr.directory/ ... FAIL
|
||||
[527/648] wss://relay.nostr.moe/ ... FAIL
|
||||
[528/648] wss://relay.nostr.pub/ ... FAIL
|
||||
[529/648] wss://relay.nostr.ro/ ... FAIL
|
||||
[530/648] wss://relay.nostr.scot/ ... FAIL
|
||||
[531/648] wss://relay.nostr.watch/ ... FAIL
|
||||
[532/648] wss://relay.nostrarabia.com/ ... FAIL
|
||||
[533/648] wss://relay.nostrasia.net/ ... FAIL
|
||||
[534/648] wss://relay.nostreggs.io/ ... FAIL
|
||||
[535/648] wss://relay.nostrich.land/ ... FAIL
|
||||
[536/648] wss://relay.nostriches.or/ ... FAIL
|
||||
[537/648] wss://relay.nostrid.com/ ... FAIL
|
||||
[538/648] wss://relay.nostronautti.fi/ ... FAIL
|
||||
[539/648] wss://relay.npubhaus.com/ ... OK
|
||||
[540/648] wss://relay.nquiz.io/ ... FAIL
|
||||
[541/648] wss://relay.nsec.app/ ... FAIL
|
||||
[542/648] wss://relay.nsnip.io/ ... FAIL
|
||||
[543/648] wss://relay.nstr.cc/ ... FAIL
|
||||
[544/648] wss://relay.nuts.cash/ ... OK
|
||||
[545/648] wss://relay.nvote.co/ ... FAIL
|
||||
[546/648] wss://relay.oke.minds.io/nostr/v1/ws ... FAIL
|
||||
[547/648] wss://relay.olas.app/ ... FAIL
|
||||
[548/648] wss://relay.orangepilldev.com/ ... FAIL
|
||||
[549/648] wss://relay.orangesync.tech/ ... FAIL
|
||||
[550/648] wss://relay.orly.dev/ ... FAIL
|
||||
[551/648] wss://relay.piazza.today/ ... FAIL
|
||||
[552/648] wss://relay.pituf.in/ ... FAIL
|
||||
[553/648] wss://relay.pleb.one/ ... FAIL
|
||||
[554/648] wss://relay.plebeian.market/ ... OK
|
||||
[555/648] wss://relay.pokernostr.com/ ... FAIL
|
||||
[556/648] wss://relay.qstr.app/ ... OK
|
||||
[557/648] wss://relay.reya.su/ ... FAIL
|
||||
[558/648] wss://relay.rip/ ... FAIL
|
||||
[559/648] wss://relay.rkus.se/ ... FAIL
|
||||
[560/648] wss://relay.roygbiv.guide/ ... FAIL
|
||||
[561/648] wss://relay.samt.st/ ... OK
|
||||
[562/648] wss://relay.satlantis.io/ ... OK
|
||||
[563/648] wss://relay.sendstr.com/ ... FAIL
|
||||
[564/648] wss://relay.sharegap.net/ ... OK
|
||||
[565/648] wss://relay.shawnyeager.com/ ... FAIL
|
||||
[566/648] wss://relay.shawnyeager.com/chat ... FAIL
|
||||
[567/648] wss://relay.shawnyeager.com/inbox ... FAIL
|
||||
[568/648] wss://relay.shawnyeager.com/outbox ... FAIL
|
||||
[569/648] wss://relay.shawnyeager.com/private ... FAIL
|
||||
[570/648] wss://relay.shosho.live/ ... FAIL
|
||||
[571/648] wss://relay.siamdev.cc/ ... FAIL
|
||||
[572/648] wss://relay.snort.social:57/ ... FAIL
|
||||
[573/648] wss://relay.sovereign.app/ ... FAIL
|
||||
[574/648] wss://relay.sovrgn.co.za/ ... OK
|
||||
[575/648] wss://relay.thibautduchene.fr/ ... FAIL
|
||||
[576/648] wss://relay.tools/ ... FAIL
|
||||
[577/648] wss://relay.towardsliberty.com/ ... FAIL
|
||||
[578/648] wss://relay.tunestr.io/ ... FAIL
|
||||
[579/648] wss://relay.utxo.one/chat ... FAIL
|
||||
[580/648] wss://relay.utxo.one/inbox ... FAIL
|
||||
[581/648] wss://relay.utxo.one/private ... FAIL
|
||||
[582/648] wss://relay.valera.co/ ... FAIL
|
||||
[583/648] wss://relay.varke.eu/ ... FAIL
|
||||
[584/648] wss://relay.verified-nostr.com/ ... FAIL
|
||||
[585/648] wss://relay.virginiafreedom.tech/ ... FAIL
|
||||
[586/648] wss://relay.wavefunc.live/ ... OK
|
||||
[587/648] wss://relay.wisp.talk/ ... OK
|
||||
[588/648] wss://relay.xavierdamman.com/ember-quartz ... FAIL
|
||||
[589/648] wss://relay.ynniv.com/ ... FAIL
|
||||
[590/648] wss://relay.zap.stream/ ... FAIL
|
||||
[591/648] wss://relay.zeh.app/ ... FAIL
|
||||
[592/648] wss://relay.zhoushen929.com/ ... FAIL
|
||||
[593/648] wss://relay1.orangesync.tech/ ... FAIL
|
||||
[594/648] wss://relay2.blogstr.app/ ... FAIL
|
||||
[595/648] wss://relay2.nostrasia.net/ ... FAIL
|
||||
[596/648] wss://relay2.orangesync.tech/ ... FAIL
|
||||
[597/648] wss://relay2.sovereignengineering.io/ ... FAIL
|
||||
[598/648] wss://relay3.openvine.co/victor ... OK
|
||||
[599/648] wss://ribo.us.nostria.app/ ... OK
|
||||
[600/648] wss://rsslay.fiatjaf.com/ ... FAIL
|
||||
[601/648] wss://rsslay.nostr.moe/ ... FAIL
|
||||
[602/648] wss://rsslay.wss//relay.nostr.info%0b%20nostr.net ... FAIL
|
||||
[603/648] wss://ryan.nostr1.com/ ... FAIL
|
||||
[604/648] wss://santo.iguanatech.net/ ... FAIL
|
||||
[605/648] wss://satdow.relaying.io/ ... FAIL
|
||||
[606/648] wss://satstacker.cloud/ ... FAIL
|
||||
[607/648] wss://sgl.rustcorp.com.au/ ... FAIL
|
||||
[608/648] wss://snort.social/ ... FAIL
|
||||
[609/648] wss://soloco.nl/ ... OK
|
||||
[610/648] wss://srtrelay.c-stellar.net/ ... FAIL
|
||||
[611/648] wss://srtrelay.c-stellar.net/karma-oscar-lima ... FAIL
|
||||
[612/648] wss://strfry.bonsai.com/ivory-xenon-juliet ... OK
|
||||
[613/648] wss://strfry.chatbett.de/ ... FAIL
|
||||
[614/648] wss://strfry.iris.to/ ... FAIL
|
||||
[615/648] wss://strfry.openhoofd.nl/ ... OK
|
||||
[616/648] wss://student.chadpolytechnic.com/ ... FAIL
|
||||
[617/648] wss://swiss.nostr.lc/ ... FAIL
|
||||
[618/648] wss://testrelay.nostr1.com/ ... FAIL
|
||||
[619/648] wss://thecitadel.nostr1.com/sierra ... FAIL
|
||||
[620/648] wss://thewildhustle.nostr1.com/ ... FAIL
|
||||
[621/648] wss://tijl.xyz/ ... FAIL
|
||||
[622/648] wss://tmp-relay.cesc.trade/ ... FAIL
|
||||
[623/648] wss://tw.purplerelay.com/ ... FAIL
|
||||
[624/648] wss://umbrel.local:4848/ ... FAIL
|
||||
[625/648] wss://umbrel.tail716804.ts.net:4848/ ... FAIL
|
||||
[626/648] wss://universe.nostrich.land/ ... FAIL
|
||||
[627/648] wss://us.azzamo.net/ ... FAIL
|
||||
[628/648] wss://us.nostr.wine/ ... FAIL
|
||||
[629/648] wss://us.purplerelay.com/ ... FAIL
|
||||
[630/648] wss://us.rbr.bio/ ... FAIL
|
||||
[631/648] wss://user.kindpag.es/ ... FAIL
|
||||
[632/648] wss://wbc.nostr1.com/ember-xenon-whiskey ... FAIL
|
||||
[633/648] wss://wons.calva.dev/ ... FAIL
|
||||
[634/648] wss://wot.azzamo.net/ ... FAIL
|
||||
[635/648] wss://wot.brightbolt.net/ ... FAIL
|
||||
[636/648] wss://wot.codingarena.top/ ... FAIL
|
||||
[637/648] wss://wot.dergigi.com/ ... FAIL
|
||||
[638/648] wss://wot.sudoscarlos.com/ ... FAIL
|
||||
[639/648] wss://wot.swarmstr.com/ ... FAIL
|
||||
[640/648] wss://wot.tealeaf.dev/ ... FAIL
|
||||
[641/648] wss://wot.zacoos.com/ ... FAIL
|
||||
[642/648] wss://wotr.relatr.xyz/ ... FAIL
|
||||
[643/648] wss://xn--%20wss-hn6d//nostr-pub.wellorder.net ... FAIL
|
||||
[644/648] wss://yarnlady.21mil.me/ ... FAIL
|
||||
[645/648] wss://yestr.me/ ... FAIL
|
||||
[646/648] wss://ynostr.yael.at/tango ... FAIL
|
||||
[647/648] wss://ynostr.yael.at/victor ... FAIL
|
||||
[648/648] ww://relay.stoner.com ... FAIL
|
||||
|
||||
=== Summary ===
|
||||
Total: 648
|
||||
Success: 95
|
||||
Failed: 553
|
||||
|
||||
Full results: /home/user/lt/client/tests/broadcast-relay-results.txt
|
||||
2593
tests/relays.json
Normal file
2593
tests/relays.json
Normal file
File diff suppressed because it is too large
Load Diff
1
tests/test-key.txt
Normal file
1
tests/test-key.txt
Normal file
@@ -0,0 +1 @@
|
||||
539ac367da733f455a703242efeafbbb0750dda352e9a641e2ccc7aadb5a3e94
|
||||
@@ -799,7 +799,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -743,7 +743,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -684,7 +684,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
//////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -466,7 +466,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, encryptContent, decryptContent } from './js/init-ndk.mjs';
|
||||
|
||||
@@ -637,6 +637,7 @@
|
||||
<button id="btnCheckProofs" class="btn cashuBtn" type="button" style="margin-bottom: 10px;">Check Proofs with Mints</button>
|
||||
<div id="divCheckProofsResult">No proof check run yet.</div>
|
||||
<button id="btnRefreshWallet" class="btn cashuBtn" type="button">Refresh Wallet State</button>
|
||||
<button id="btnRepublishWallet" class="btn cashuBtn" type="button" style="margin-top: 5px;">Republish Wallet (kind 17375)</button>
|
||||
</div>
|
||||
|
||||
<div id="divZapsSection" class="sidenavSection">
|
||||
@@ -718,7 +719,7 @@
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./ndk-core.bundle.js?v=wallet-hotfix-3"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
@@ -728,7 +729,7 @@
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=mint-discovery-3';
|
||||
import { createCashuWalletController } from './js/cashu-wallet.mjs?v=nip60-republish-1';
|
||||
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
@@ -2348,6 +2349,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
const btnRepublishWallet = document.getElementById('btnRepublishWallet');
|
||||
btnRepublishWallet?.addEventListener('click', async () => {
|
||||
try {
|
||||
if (!walletController?.hasWallet?.()) {
|
||||
setStatus('No wallet to republish', true);
|
||||
return;
|
||||
}
|
||||
setStatus('Republishing wallet event (kind 17375)...');
|
||||
await walletController.republishWallet();
|
||||
await refreshAllWalletViews();
|
||||
setStatus('Wallet republished successfully');
|
||||
} catch (error) {
|
||||
console.error('[cashu.html] Republish wallet failed:', error);
|
||||
setStatus(error.message || 'Republish failed', true);
|
||||
}
|
||||
});
|
||||
|
||||
btnSaveZapsSettings?.addEventListener('click', async () => {
|
||||
try {
|
||||
const amount = Number(inpZapDefaultAmount?.value || 0);
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -1388,6 +1388,136 @@ a.nostr-embed-preview-text:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Zap rail selector (Phase 4 — nutzap vs Lightning) */
|
||||
.zap-rail-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.zap-rail-option {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--muted-color);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zap-rail-option.active {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.zap-rail-single {
|
||||
display: block;
|
||||
border: 1px solid var(--accent-color);
|
||||
border-radius: 8px;
|
||||
background: var(--background-color);
|
||||
color: var(--accent-color);
|
||||
font-family: var(--font-family);
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.zap-selector-balance {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.zap-selector-warning {
|
||||
font-size: 12px;
|
||||
color: var(--accent-color);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zap-selector-mint-info {
|
||||
font-size: 12px;
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.zap-selector-error {
|
||||
font-size: 13px;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.zap-send:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Nutzap recipient mint list (promptNutzapDetails) */
|
||||
.zap-selector-mint-list {
|
||||
list-style: none;
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.zap-selector-mint-list-item {
|
||||
font-size: 11px;
|
||||
color: var(--muted-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.zap-selector-mint-list-item.shared {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Nutzap interaction item (post footer) */
|
||||
.interaction-item.nutzap {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.interaction-item.nutzap .interaction-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.interaction-item.nutzap.zap-pending {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.interaction-item.nutzap.zap-pending .interaction-icon,
|
||||
.interaction-item.nutzap.zap-pending .interaction-icon.active,
|
||||
.interaction-item.nutzap.zap-pending .interaction-count {
|
||||
color: var(--muted-color) !important;
|
||||
transition: color 240ms linear !important;
|
||||
}
|
||||
|
||||
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon,
|
||||
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-icon.active,
|
||||
.interaction-item.nutzap.zap-pending.zap-pending-accent .interaction-count {
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
.interaction-item.nutzap.zap-success,
|
||||
.interaction-item.nutzap.zap-success .interaction-icon,
|
||||
.interaction-item.nutzap.zap-success .interaction-icon.active,
|
||||
.interaction-item.nutzap.zap-success .interaction-count {
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
/* Dimmed (capability unavailable) interaction items */
|
||||
.interaction-item.dimmed {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.interaction-item.dimmed:hover {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
.interaction-item.dimmed:hover .interaction-icon {
|
||||
color: var(--muted-color);
|
||||
}
|
||||
|
||||
/* ************************************************************************* */
|
||||
/* AUTHOR HEADER */
|
||||
/* ************************************************************************* */
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -513,7 +513,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
|
||||
|
||||
@@ -915,7 +915,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -342,6 +342,7 @@
|
||||
<div class="panelTitle">Kinds</div>
|
||||
<div class="btnRow">
|
||||
<button id="btnRefreshKinds" class="btnMini" type="button">Refresh Cache</button>
|
||||
<button id="btnGetAllEvents" class="btnMini" type="button">Get all events</button>
|
||||
</div>
|
||||
<div id="divKindsStatus" class="hint">Loading...</div>
|
||||
<div id="divKindsList" class="listWrap"></div>
|
||||
@@ -432,7 +433,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
@@ -683,12 +684,16 @@
|
||||
let selectedKindSub = null;
|
||||
let currentDetailEvent = null;
|
||||
let lastDecryptResult = null;
|
||||
let allEventsFetchSub = null;
|
||||
let allEventsFetchInFlight = false;
|
||||
let allEventsFetchTimeoutId = null;
|
||||
|
||||
const eventsByKind = new Map(); // kind -> Map(eventKey, event)
|
||||
const selectedEventKeys = new Set();
|
||||
const eventRelayPresence = new Map(); // eventKey -> Map(relayUrl, boolean)
|
||||
const eventRelayPublishState = new Map(); // eventKey -> Map(relayUrl, 'idle'|'checking'|'publishing'|'failed')
|
||||
let subscribedRelayUrls = [];
|
||||
let readableRelayUrls = []; // read/both relays only (excludes write-only) for Refresh Kind queries
|
||||
let refreshKindDebugInFlight = false;
|
||||
const autoRefreshedKinds = new Set();
|
||||
const BRAILLE_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
@@ -703,6 +708,7 @@
|
||||
const divFooterRight = document.getElementById('divFooterRight');
|
||||
|
||||
const btnRefreshKinds = document.getElementById('btnRefreshKinds');
|
||||
const btnGetAllEvents = document.getElementById('btnGetAllEvents');
|
||||
const divKindsStatus = document.getElementById('divKindsStatus');
|
||||
const divKindsList = document.getElementById('divKindsList');
|
||||
|
||||
@@ -1022,12 +1028,26 @@
|
||||
? rows.filter((r) => r?.fromRelayList === true)
|
||||
: rows;
|
||||
|
||||
// subscribedRelayUrls includes all relays (read, write, both) so that
|
||||
// write-only relays still appear as publish-target chips and are valid
|
||||
// targets for the "publish to missing relays" flow.
|
||||
const urls = [...new Set(scopedRows
|
||||
.map((r) => String(r?.url || '').trim())
|
||||
.filter(Boolean))];
|
||||
|
||||
subscribedRelayUrls = urls;
|
||||
|
||||
// readableRelayUrls excludes write-only relays (NIP-65 type === 'write')
|
||||
// so the Refresh Kind query loop never tries to READ from a write-only
|
||||
// relay. Falls back to the full list when type info is unavailable.
|
||||
const hasTypeinfo = scopedRows.some((r) => r?.type);
|
||||
readableRelayUrls = hasTypeinfo
|
||||
? [...new Set(scopedRows
|
||||
.filter((r) => r?.type !== 'write')
|
||||
.map((r) => String(r?.url || '').trim())
|
||||
.filter(Boolean))]
|
||||
: [...urls];
|
||||
|
||||
for (const relayMap of eventRelayPresence.values()) {
|
||||
for (const url of subscribedRelayUrls) {
|
||||
if (!relayMap.has(url)) relayMap.set(url, false);
|
||||
@@ -1396,16 +1416,22 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedKindsAndEventTotals() {
|
||||
const kinds = getKindsSorted();
|
||||
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
||||
return { kindsCount: kinds.length, totalEvents };
|
||||
}
|
||||
|
||||
function renderKinds() {
|
||||
const kinds = getKindsSorted();
|
||||
if (!kinds.length) {
|
||||
divKindsList.innerHTML = '<div class="hint">No cached events found.</div>';
|
||||
divKindsStatus.textContent = '0 kinds · 0 events';
|
||||
if (!allEventsFetchInFlight) divKindsStatus.textContent = '0 kinds · 0 events';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalEvents = kinds.reduce((sum, k) => sum + getEventsForKind(k).length, 0);
|
||||
divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
if (!allEventsFetchInFlight) divKindsStatus.textContent = `${kinds.length} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
|
||||
divKindsList.innerHTML = kinds.map((kind) => {
|
||||
const count = getEventsForKind(kind).length;
|
||||
@@ -1682,6 +1708,63 @@
|
||||
selectedKindSub = subscribe({ kinds: [Number(kind)], authors: [currentPubkey], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
}
|
||||
|
||||
function clearAllEventsFetchTimeout() {
|
||||
if (allEventsFetchTimeoutId) {
|
||||
clearTimeout(allEventsFetchTimeoutId);
|
||||
allEventsFetchTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllEventsFetchSubscription() {
|
||||
try {
|
||||
if (allEventsFetchSub && typeof allEventsFetchSub.close === 'function') allEventsFetchSub.close();
|
||||
} catch (_error) { }
|
||||
allEventsFetchSub = null;
|
||||
}
|
||||
|
||||
function finalizeGetAllEventsFetch(statusText) {
|
||||
clearAllEventsFetchTimeout();
|
||||
closeAllEventsFetchSubscription();
|
||||
allEventsFetchInFlight = false;
|
||||
if (btnGetAllEvents) btnGetAllEvents.disabled = false;
|
||||
if (statusText) {
|
||||
divKindsStatus.textContent = statusText;
|
||||
} else {
|
||||
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
|
||||
divKindsStatus.textContent = `${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`;
|
||||
}
|
||||
}
|
||||
|
||||
async function runOneShotGetAllEventsFetch() {
|
||||
if (allEventsFetchInFlight) return;
|
||||
if (!currentPubkey) {
|
||||
divKindsStatus.textContent = 'Get all events failed: user pubkey unavailable';
|
||||
return;
|
||||
}
|
||||
|
||||
allEventsFetchInFlight = true;
|
||||
if (btnGetAllEvents) btnGetAllEvents.disabled = true;
|
||||
|
||||
const beforeTotals = getCachedKindsAndEventTotals();
|
||||
divKindsStatus.textContent = `Getting all events from relays for ${shortHex(currentPubkey)}...`;
|
||||
|
||||
try {
|
||||
closeAllEventsFetchSubscription();
|
||||
allEventsFetchSub = subscribe(
|
||||
{ authors: [currentPubkey] },
|
||||
{ closeOnEose: true, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
|
||||
allEventsFetchTimeoutId = setTimeout(() => {
|
||||
const afterTotals = getCachedKindsAndEventTotals();
|
||||
const added = Math.max(0, afterTotals.totalEvents - beforeTotals.totalEvents);
|
||||
finalizeGetAllEventsFetch(`Get all events timed out · +${added.toLocaleString()} events (${afterTotals.totalEvents.toLocaleString()} total)`);
|
||||
}, 30000);
|
||||
} catch (error) {
|
||||
finalizeGetAllEventsFetch(`Get all events failed: ${error?.message || String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectKind(kind, options = {}) {
|
||||
const { autoRefresh = true } = options;
|
||||
|
||||
@@ -1747,8 +1830,13 @@
|
||||
});
|
||||
|
||||
setSubscribedRelayUrlsFromRelayData(relayData);
|
||||
const relayUrls = [...subscribedRelayUrls];
|
||||
console.log('[event-management] refresh:relayUrls scoped for query', relayUrls);
|
||||
// Use readableRelayUrls (excludes write-only relays) so Refresh Kind
|
||||
// never tries to READ from a write-only relay like sendit.nosflare.com.
|
||||
const relayUrls = [...readableRelayUrls];
|
||||
console.log('[event-management] refresh:relayUrls scoped for query (read-only)', relayUrls, {
|
||||
subscribedRelayUrls: subscribedRelayUrls,
|
||||
readableRelayUrls: readableRelayUrls
|
||||
});
|
||||
|
||||
if (!relayUrls.length) {
|
||||
divKindsStatus.textContent = `Kind ${targetKind}: no relays found in kind 10002 relay list`;
|
||||
@@ -1992,6 +2080,8 @@
|
||||
});
|
||||
|
||||
logoutButton?.addEventListener('click', async () => {
|
||||
closeAllEventsFetchSubscription();
|
||||
clearAllEventsFetchTimeout();
|
||||
try { await logout(); } catch (error) { console.error('[event-management.html] logout failed', error); }
|
||||
});
|
||||
|
||||
@@ -2004,6 +2094,10 @@
|
||||
}
|
||||
});
|
||||
|
||||
btnGetAllEvents?.addEventListener('click', async () => {
|
||||
await runOneShotGetAllEventsFetch();
|
||||
});
|
||||
|
||||
inpEventSearch.addEventListener('input', renderEvents);
|
||||
chkShowSuperseded?.addEventListener('change', renderEvents);
|
||||
|
||||
@@ -2319,6 +2413,15 @@
|
||||
renderEvents();
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEose', (event) => {
|
||||
if (!allEventsFetchInFlight || !allEventsFetchSub?.subId) return;
|
||||
const eoseSubId = String(event?.detail?.subId || '');
|
||||
if (!eoseSubId || eoseSubId !== String(allEventsFetchSub.subId)) return;
|
||||
|
||||
const { kindsCount, totalEvents } = getCachedKindsAndEventTotals();
|
||||
finalizeGetAllEventsFetch(`Get all events complete · ${kindsCount} kinds · ${totalEvents.toLocaleString()} cached events`);
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEvent', (event) => {
|
||||
const evt = event?.detail;
|
||||
if (!evt || !Number.isFinite(Number(evt.kind)) || !evt.id) return;
|
||||
|
||||
729
www/feed-old.html
Normal file
729
www/feed-old.html
Normal file
@@ -0,0 +1,729 @@
|
||||
<!DOCTYPE html>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html lang="en" dir="ltr">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>FEED</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
<link rel="stylesheet" href="./css/post-composer.css" />
|
||||
<link rel="stylesheet" href="./css/dot-menu.css" />
|
||||
|
||||
<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" />
|
||||
<script src="./js/vendor/svg.min.js"></script>
|
||||
|
||||
<style>
|
||||
#divBody {
|
||||
flex-direction: column !important;
|
||||
flex-wrap: nowrap !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
align-content: flex-start !important;
|
||||
gap: 10px;
|
||||
overflow-y: auto;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
#divHint,
|
||||
#divFeed,
|
||||
#btnSeeMore {
|
||||
width: 80%;
|
||||
min-width: 300px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
#divHint {
|
||||
text-align: center;
|
||||
color: var(--muted-color);
|
||||
font-size: 80%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
#divFeed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#btnSeeMore {
|
||||
display: none;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.divPostItem .post-composer-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="divSvgHam" class="divHeaderButtons"></div>
|
||||
|
||||
<div id="divHeader">
|
||||
<div id="divHeaderFlexLeft"></div>
|
||||
<div id="divHeaderFlexCenter">
|
||||
<div class="divHeaderText">FEED</div>
|
||||
</div>
|
||||
<div id="divHeaderFlexRight"></div>
|
||||
</div>
|
||||
|
||||
<div id="divBody">
|
||||
<div id="divHint">Loading follows…</div>
|
||||
<div id="divFeed"></div>
|
||||
<button id="btnSeeMore" class="btn">See More</button>
|
||||
</div>
|
||||
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<button id="commentsToggleButton">Comments</button>
|
||||
</div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
</div>
|
||||
|
||||
<div id="divSideNav">
|
||||
<div id="divSideNavHeader"></div>
|
||||
<div id="divSideNavBody">
|
||||
<div id="divFiles"></div>
|
||||
<div id="divFeedSettings" class="sidenavSection">
|
||||
<div class="sidenavSectionTitle">Feed</div>
|
||||
<label class="sidenavRowToggle" for="chkAutoplayVideos">
|
||||
<span>Autoplay videos</span>
|
||||
<input id="chkAutoplayVideos" type="checkbox" />
|
||||
</label>
|
||||
</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">loading...</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>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
initNDKPage,
|
||||
getPubkey,
|
||||
injectHeaderAvatar,
|
||||
subscribe,
|
||||
disconnect,
|
||||
getVersion,
|
||||
queryCache,
|
||||
ndkFetchEvents,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
publishEvent,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onUserSettings,
|
||||
ensureMuteListLoaded,
|
||||
isEventMuted,
|
||||
addMute
|
||||
} 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';
|
||||
import { initPostCards } from './js/post-interactions2.mjs';
|
||||
import { mountComposer } from './js/post-composer.mjs';
|
||||
|
||||
const INITIAL_POSTS_LOAD = 10;
|
||||
const SEE_MORE_INCREMENT = 20;
|
||||
const MIN_BOOTSTRAP_POSTS = 10;
|
||||
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
|
||||
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
|
||||
|
||||
let currentPubkey = null;
|
||||
let updateIntervalId = null;
|
||||
|
||||
let unsubscribeUserSettings = null;
|
||||
|
||||
let hamburgerInstance = null;
|
||||
let logoutHamburger = null;
|
||||
let themeToggleHamburger = null;
|
||||
let isDarkMode = false;
|
||||
let isNavOpen = false;
|
||||
|
||||
const posts = [];
|
||||
const postIds = new Set();
|
||||
const renderedPostIds = new Set();
|
||||
let displayCount = INITIAL_POSTS_LOAD;
|
||||
|
||||
let followedPubkeys = [];
|
||||
const feedPubkeys = new Set();
|
||||
|
||||
const divBody = document.getElementById('divBody');
|
||||
const divSideNav = document.getElementById('divSideNav');
|
||||
const divFooterRight = document.getElementById('divFooterRight');
|
||||
const divFeed = document.getElementById('divFeed');
|
||||
const divHint = document.getElementById('divHint');
|
||||
const btnSeeMore = document.getElementById('btnSeeMore');
|
||||
const chkAutoplayVideos = document.getElementById('chkAutoplayVideos');
|
||||
|
||||
let autoplayVideosEnabled = false;
|
||||
let liveFeedSubscriptionKey = '';
|
||||
|
||||
const inlineComposerInstances = new Map(); // postId -> { hostEl, instance, tags }
|
||||
|
||||
let isBootstrappingFeed = false;
|
||||
let hasBootstrappedFeed = false;
|
||||
const bufferedFeedEvents = [];
|
||||
|
||||
function toNostrNoteRef(postId) {
|
||||
try {
|
||||
return window?.NostrTools?.nip19?.noteEncode
|
||||
? window.NostrTools.nip19.noteEncode(postId)
|
||||
: postId;
|
||||
} catch (_) {
|
||||
return postId;
|
||||
}
|
||||
}
|
||||
|
||||
function focusInlineComposer(hostEl, content = '') {
|
||||
if (!hostEl) return false;
|
||||
hostEl.innerText = content || '';
|
||||
hostEl.focus();
|
||||
document.dispatchEvent(new Event('selectionchange'));
|
||||
hostEl.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
window.requestAnimationFrame(() => {
|
||||
hostEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function getOrCreateInlineComposer(postId, postEl) {
|
||||
if (!postId || !postEl) return null;
|
||||
|
||||
const existing = inlineComposerInstances.get(postId);
|
||||
if (existing?.hostEl?.isConnected && existing?.instance) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const hostEl = document.createElement('div');
|
||||
hostEl.className = 'inlinePostComposerInput';
|
||||
hostEl.dataset.inlineComposerFor = postId;
|
||||
postEl.appendChild(hostEl);
|
||||
|
||||
const entry = {
|
||||
hostEl,
|
||||
tags: [],
|
||||
instance: null
|
||||
};
|
||||
|
||||
const instance = mountComposer(hostEl, {
|
||||
currentPubkey,
|
||||
followedProfiles: [],
|
||||
showUploadIcon: true,
|
||||
showPreview: true,
|
||||
autoHideOnSubmit: true,
|
||||
hideOnEscape: true,
|
||||
onSubmit: async (content) => {
|
||||
const text = String(content || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
await publishEvent({
|
||||
kind: 1,
|
||||
content: text,
|
||||
tags: entry.tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
entry.instance = instance;
|
||||
inlineComposerInstances.set(postId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function handleComposerCommentIntent({ postId, postPubkey }) {
|
||||
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
||||
if (!postEl) return false;
|
||||
|
||||
const entry = getOrCreateInlineComposer(postId, postEl);
|
||||
if (!entry) return false;
|
||||
entry.tags = [
|
||||
['e', postId, '', 'reply'],
|
||||
['p', postPubkey]
|
||||
];
|
||||
if (entry.instance?.show) entry.instance.show();
|
||||
return focusInlineComposer(entry.hostEl, '');
|
||||
}
|
||||
|
||||
function handleComposerQuoteIntent({ postId, postPubkey }) {
|
||||
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
||||
if (!postEl) return false;
|
||||
|
||||
const noteRef = toNostrNoteRef(postId);
|
||||
const entry = getOrCreateInlineComposer(postId, postEl);
|
||||
if (!entry) return false;
|
||||
entry.tags = [
|
||||
['e', postId, '', 'mention'],
|
||||
['p', postPubkey],
|
||||
['q', postId]
|
||||
];
|
||||
if (entry.instance?.show) entry.instance.show();
|
||||
return focusInlineComposer(entry.hostEl, `nostr:${noteRef}`);
|
||||
}
|
||||
|
||||
async function handleMuteIntent({ eventData }) {
|
||||
const targetPubkey = String(eventData?.pubkey || '').trim();
|
||||
if (!targetPubkey || targetPubkey === currentPubkey) return;
|
||||
|
||||
const ok = window.confirm(`Mute ${targetPubkey.slice(0, 8)}…${targetPubkey.slice(-4)}?`);
|
||||
if (!ok) return;
|
||||
|
||||
await addMute('p', targetPubkey, false);
|
||||
|
||||
for (let i = posts.length - 1; i >= 0; i -= 1) {
|
||||
if (posts[i]?.pubkey === targetPubkey) {
|
||||
postIds.delete(posts[i]?.id);
|
||||
posts.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
renderFeed();
|
||||
}
|
||||
|
||||
const cards = initPostCards({
|
||||
subscribe,
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
ndkFetchEvents,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onCommentIntent: handleComposerCommentIntent,
|
||||
onQuoteIntent: handleComposerQuoteIntent,
|
||||
onMuteIntent: handleMuteIntent
|
||||
});
|
||||
|
||||
function initHamburgerMenu() {
|
||||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||
foreground: 'var(--primary-color)',
|
||||
background: 'var(--secondary-color)',
|
||||
hover: 'var(--accent-color)'
|
||||
});
|
||||
hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
|
||||
function openNav() {
|
||||
divSideNav.style.zIndex = 3;
|
||||
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
|
||||
isNavOpen = true;
|
||||
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
|
||||
|
||||
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)'
|
||||
});
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
|
||||
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
|
||||
}
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
divSideNav.style.width = '0vw';
|
||||
divSideNav.style.zIndex = -1;
|
||||
isNavOpen = false;
|
||||
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
|
||||
}
|
||||
|
||||
function toggleNav() {
|
||||
isNavOpen ? closeNav() : openNav();
|
||||
}
|
||||
|
||||
function isOriginalPost(event) {
|
||||
const eTags = event.tags?.filter(t => t[0] === 'e') || [];
|
||||
if (eTags.length === 0) return true;
|
||||
return eTags.every(t => t[3] === 'mention');
|
||||
}
|
||||
|
||||
function applyVideoAutoplaySetting(enabled) {
|
||||
autoplayVideosEnabled = !!enabled;
|
||||
if (chkAutoplayVideos) chkAutoplayVideos.checked = autoplayVideosEnabled;
|
||||
|
||||
const videoEls = Array.from(divFeed.querySelectorAll('video'));
|
||||
videoEls.forEach((videoEl) => {
|
||||
videoEl.controls = true;
|
||||
videoEl.playsInline = true;
|
||||
videoEl.setAttribute('playsinline', '');
|
||||
videoEl.preload = 'metadata';
|
||||
|
||||
if (autoplayVideosEnabled) {
|
||||
videoEl.autoplay = true;
|
||||
videoEl.muted = true;
|
||||
videoEl.setAttribute('autoplay', '');
|
||||
videoEl.setAttribute('muted', '');
|
||||
const playPromise = videoEl.play?.();
|
||||
if (playPromise?.catch) playPromise.catch(() => {});
|
||||
} else {
|
||||
videoEl.autoplay = false;
|
||||
videoEl.muted = false;
|
||||
videoEl.removeAttribute('autoplay');
|
||||
videoEl.removeAttribute('muted');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function applySettings(settings) {
|
||||
const nextAutoplayEnabled = !!settings?.feed?.videoAutoplay;
|
||||
applyVideoAutoplaySetting(nextAutoplayEnabled);
|
||||
}
|
||||
|
||||
async function persistAutoplaySetting(enabled) {
|
||||
try {
|
||||
await patchUserSettings({
|
||||
feed: {
|
||||
videoAutoplay: !!enabled
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[feed.html] Failed to persist feed autoplay setting:', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
function upsertFeedPost(evt) {
|
||||
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
|
||||
if (isEventMuted(evt)) return false;
|
||||
postIds.add(evt.id);
|
||||
posts.push(evt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderFeed() {
|
||||
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
const toShow = posts.slice(0, displayCount);
|
||||
|
||||
divFeed.innerHTML = '';
|
||||
renderedPostIds.clear();
|
||||
|
||||
toShow.forEach((post) => {
|
||||
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
|
||||
divFeed.appendChild(postEl);
|
||||
renderedPostIds.add(post.id);
|
||||
});
|
||||
|
||||
cards.wireInteractions(toShow.map(p => p.id), { currentPubkey });
|
||||
|
||||
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
|
||||
divHint.textContent = followedPubkeys.length > 0
|
||||
? `${posts.length} posts from ${followedPubkeys.length} follows`
|
||||
: 'Follow users to populate your feed.';
|
||||
divFooterRight.textContent = `${posts.length} posts`;
|
||||
}
|
||||
|
||||
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
const filters = { kinds: [1], authors, limit };
|
||||
if (typeof since === 'number' && Number.isFinite(since) && since > 0) {
|
||||
filters.since = since;
|
||||
}
|
||||
|
||||
const cached = await queryCache(filters).catch(() => []);
|
||||
cached.forEach((evt1) => {
|
||||
upsertFeedPost(evt1);
|
||||
});
|
||||
|
||||
if (renderAfterCache && posts.length > 0) {
|
||||
renderFeed();
|
||||
}
|
||||
|
||||
void ndkFetchEvents(filters).then((fresh) => {
|
||||
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
|
||||
upsertFeedPost(evt1);
|
||||
});
|
||||
if (posts.length > 0) {
|
||||
renderFeed();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function bootstrapFeedPosts(authors) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) {
|
||||
hasBootstrappedFeed = true;
|
||||
renderFeed();
|
||||
return;
|
||||
}
|
||||
|
||||
isBootstrappingFeed = true;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await Promise.allSettled(
|
||||
FEED_BOOTSTRAP_WINDOWS_SECONDS.map((windowSeconds) =>
|
||||
fetchFeedWindow(authors, {
|
||||
since: now - windowSeconds,
|
||||
limit: FEED_BOOTSTRAP_WINDOW_LIMIT,
|
||||
renderAfterCache: true
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (posts.length < MIN_BOOTSTRAP_POSTS) {
|
||||
await fetchFeedWindow(authors, {
|
||||
since: 0,
|
||||
limit: FEED_BOOTSTRAP_WINDOW_LIMIT * 2,
|
||||
renderAfterCache: true
|
||||
});
|
||||
}
|
||||
|
||||
bufferedFeedEvents.forEach((evt1) => {
|
||||
if (feedPubkeys.has(evt1.pubkey)) {
|
||||
upsertFeedPost(evt1);
|
||||
}
|
||||
});
|
||||
bufferedFeedEvents.length = 0;
|
||||
|
||||
isBootstrappingFeed = false;
|
||||
hasBootstrappedFeed = true;
|
||||
renderFeed();
|
||||
}
|
||||
|
||||
function ensureLiveFeedSubscription() {
|
||||
const authors = Array.from(feedPubkeys).filter(Boolean);
|
||||
if (authors.length === 0) return;
|
||||
|
||||
const key = authors.slice().sort().join(',');
|
||||
if (key === liveFeedSubscriptionKey) return;
|
||||
|
||||
const newestKnown = posts.reduce((max, p) => Math.max(max, p?.created_at || 0), 0);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
|
||||
|
||||
subscribe(
|
||||
{ kinds: [1], authors, since },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
);
|
||||
|
||||
liveFeedSubscriptionKey = key;
|
||||
console.log('[feed.html] Live feed subscription active', { authors: authors.length, since });
|
||||
}
|
||||
|
||||
async function ingestContactList(evt) {
|
||||
if (!evt || evt.kind !== 3 || evt.pubkey !== currentPubkey) return;
|
||||
const pTags = evt.tags?.filter(tag => tag[0] === 'p' && tag[1]) || [];
|
||||
followedPubkeys = pTags.map(tag => tag[1]);
|
||||
|
||||
const newAuthors = followedPubkeys.filter((pk) => !feedPubkeys.has(pk));
|
||||
newAuthors.forEach((pk) => feedPubkeys.add(pk));
|
||||
|
||||
ensureLiveFeedSubscription();
|
||||
|
||||
const allAuthors = Array.from(feedPubkeys);
|
||||
|
||||
if (!hasBootstrappedFeed) {
|
||||
await bootstrapFeedPosts(allAuthors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newAuthors.length > 0) {
|
||||
try {
|
||||
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
|
||||
renderFeed();
|
||||
} catch (_) {
|
||||
renderFeed();
|
||||
}
|
||||
} else {
|
||||
renderFeed();
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
if (updateIntervalId) {
|
||||
clearInterval(updateIntervalId);
|
||||
updateIntervalId = null;
|
||||
}
|
||||
cards.destroy();
|
||||
disconnect();
|
||||
if (window.NOSTR_LOGIN_LITE?.logout) {
|
||||
await window.NOSTR_LOGIN_LITE.logout();
|
||||
}
|
||||
if (unsubscribeUserSettings) {
|
||||
unsubscribeUserSettings();
|
||||
unsubscribeUserSettings = null;
|
||||
}
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
location.reload(true);
|
||||
}
|
||||
|
||||
async function updateFooter() {
|
||||
try {
|
||||
await updateFooterRelayStatus();
|
||||
await updateSidenavRelaySection();
|
||||
await updateBlossomSection();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
try {
|
||||
const versionInfo = await getVersion();
|
||||
document.getElementById('versionDisplay').textContent = versionInfo.VERSION;
|
||||
|
||||
initHamburgerMenu();
|
||||
document.getElementById('divSvgHam')?.addEventListener('click', toggleNav);
|
||||
|
||||
document.getElementById('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');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logoutButton')?.addEventListener('click', logout);
|
||||
|
||||
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
|
||||
const nextVisible = !cards.getCommentsVisible();
|
||||
cards.setCommentsVisible(nextVisible);
|
||||
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
|
||||
});
|
||||
|
||||
btnSeeMore.addEventListener('click', () => {
|
||||
displayCount += SEE_MORE_INCREMENT;
|
||||
renderFeed();
|
||||
});
|
||||
|
||||
chkAutoplayVideos?.addEventListener('change', async () => {
|
||||
const nextEnabled = !!chkAutoplayVideos.checked;
|
||||
applyVideoAutoplaySetting(nextEnabled);
|
||||
await persistAutoplaySetting(nextEnabled);
|
||||
});
|
||||
|
||||
await initNDKPage();
|
||||
currentPubkey = await getPubkey();
|
||||
await injectHeaderAvatar(currentPubkey);
|
||||
|
||||
await ensureMuteListLoaded();
|
||||
|
||||
try {
|
||||
const settings = await getUserSettings();
|
||||
applySettings(settings || {});
|
||||
} catch (error) {
|
||||
console.warn('[feed.html] Failed to load user settings:', error?.message || error);
|
||||
applySettings({});
|
||||
}
|
||||
|
||||
if (!unsubscribeUserSettings) {
|
||||
unsubscribeUserSettings = onUserSettings((settings) => {
|
||||
applySettings(settings || {});
|
||||
});
|
||||
}
|
||||
|
||||
initFooterRelayStatus();
|
||||
initSidenavRelaySection();
|
||||
await initBlossomSection();
|
||||
initAiSectionWithLocalConfig();
|
||||
await updateFooter();
|
||||
updateIntervalId = setInterval(updateFooter, 1000);
|
||||
|
||||
window.addEventListener('ndkRelayActivity', (e) => {
|
||||
setRelayActivityState(e.detail.relayUrl, e.detail.activity);
|
||||
});
|
||||
|
||||
window.addEventListener('ndkEvent', async (e) => {
|
||||
const evt = e.detail;
|
||||
if (!evt) return;
|
||||
|
||||
if (evt.kind === 3 && evt.pubkey === currentPubkey) {
|
||||
await ingestContactList(evt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.kind === 1 && feedPubkeys.has(evt.pubkey) && isOriginalPost(evt)) {
|
||||
if (isBootstrappingFeed) {
|
||||
bufferedFeedEvents.push(evt);
|
||||
return;
|
||||
}
|
||||
if (!upsertFeedPost(evt)) return;
|
||||
renderFeed();
|
||||
}
|
||||
});
|
||||
|
||||
subscribe({ kinds: [3], authors: [currentPubkey], limit: 1 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
||||
|
||||
const cachedContactEvents = await queryCache({ kinds: [3], authors: [currentPubkey], limit: 1 }).catch(() => []);
|
||||
if (cachedContactEvents.length > 0) {
|
||||
const latest = cachedContactEvents.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0];
|
||||
await ingestContactList(latest);
|
||||
}
|
||||
|
||||
if (feedPubkeys.size === 0) {
|
||||
divHint.textContent = 'Follow users to populate your feed.';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[feed.html] Initialization failed:', error);
|
||||
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
138
www/feed.html
138
www/feed.html
@@ -87,9 +87,7 @@
|
||||
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<button id="commentsToggleButton">Comments</button>
|
||||
</div>
|
||||
<div id="divFooterCenter" class="divFooterBox"></div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
</div>
|
||||
@@ -139,7 +137,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
@@ -157,13 +155,16 @@
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetBalance,
|
||||
walletGetMints,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onUserSettings,
|
||||
ensureMuteListLoaded,
|
||||
isEventMuted,
|
||||
addMute
|
||||
addMute,
|
||||
warmOutbox
|
||||
} from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
@@ -178,6 +179,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const MIN_BOOTSTRAP_POSTS = 10;
|
||||
const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600];
|
||||
const FEED_BOOTSTRAP_WINDOW_LIMIT = 120;
|
||||
const MAX_STORED_POSTS = 500;
|
||||
|
||||
let currentPubkey = null;
|
||||
let updateIntervalId = null;
|
||||
@@ -281,18 +283,10 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
return entry;
|
||||
}
|
||||
|
||||
function handleComposerCommentIntent({ postId, postPubkey }) {
|
||||
const postEl = document.querySelector(`.divPostItem[data-post-id="${postId}"]`);
|
||||
if (!postEl) return false;
|
||||
|
||||
const entry = getOrCreateInlineComposer(postId, postEl);
|
||||
if (!entry) return false;
|
||||
entry.tags = [
|
||||
['e', postId, '', 'reply'],
|
||||
['p', postPubkey]
|
||||
];
|
||||
if (entry.instance?.show) entry.instance.show();
|
||||
return focusInlineComposer(entry.hostEl, '');
|
||||
function handleComposerCommentIntent({ postId }) {
|
||||
if (!postId) return false;
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleComposerQuoteIntent({ postId, postPubkey }) {
|
||||
@@ -335,11 +329,14 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
ndkFetchEvents,
|
||||
queryCache,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetBalance,
|
||||
walletGetMints,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -348,6 +345,12 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
onMuteIntent: handleMuteIntent
|
||||
});
|
||||
|
||||
// Disable inline comment rendering — the new feed shows posts only.
|
||||
// Comments are viewed on the post-feed.html thread page (new tab).
|
||||
if (typeof cards.setCommentsVisible === 'function') {
|
||||
cards.setCommentsVisible(false);
|
||||
}
|
||||
|
||||
function initHamburgerMenu() {
|
||||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||
foreground: 'var(--primary-color)',
|
||||
@@ -447,11 +450,24 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
}
|
||||
}
|
||||
|
||||
function prunePostsArray() {
|
||||
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
if (posts.length > MAX_STORED_POSTS) {
|
||||
posts.length = MAX_STORED_POSTS;
|
||||
postIds.clear();
|
||||
posts.forEach((p) => postIds.add(p.id));
|
||||
for (const id of Array.from(renderedPostIds)) {
|
||||
if (!postIds.has(id)) renderedPostIds.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function upsertFeedPost(evt) {
|
||||
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
|
||||
if (isEventMuted(evt)) return false;
|
||||
postIds.add(evt.id);
|
||||
posts.push(evt);
|
||||
if (posts.length > MAX_STORED_POSTS + 50) prunePostsArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -477,6 +493,62 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
divFooterRight.textContent = `${posts.length} posts`;
|
||||
}
|
||||
|
||||
function prependPostCard(post) {
|
||||
if (!post?.id || renderedPostIds.has(post.id)) return false;
|
||||
|
||||
// If feed is empty or not yet rendered, fall back to full rebuild.
|
||||
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
|
||||
renderFeed();
|
||||
return false;
|
||||
}
|
||||
|
||||
const postCreatedAt = post.created_at || 0;
|
||||
|
||||
// Find the correct insertion point by scanning visible children's created_at.
|
||||
// Posts are displayed newest-first, so we insert before the first child
|
||||
// that is older than (or equal to) the new post.
|
||||
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
|
||||
let inserted = false;
|
||||
|
||||
for (const child of divFeed.children) {
|
||||
const childId = child.getAttribute('data-post-id');
|
||||
if (!childId) continue;
|
||||
const childPost = posts.find((p) => p.id === childId);
|
||||
if (!childPost) continue;
|
||||
const childCreatedAt = childPost.created_at || 0;
|
||||
if (postCreatedAt >= childCreatedAt) {
|
||||
divFeed.insertBefore(postEl, child);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
// Post is older than all visible posts — append at the bottom.
|
||||
divFeed.appendChild(postEl);
|
||||
}
|
||||
|
||||
renderedPostIds.add(post.id);
|
||||
cards.wireInteractions([post.id], { currentPubkey });
|
||||
|
||||
// Trim DOM to displayCount.
|
||||
while (divFeed.children.length > displayCount) {
|
||||
const lastChild = divFeed.lastElementChild;
|
||||
if (!lastChild) break;
|
||||
const removedId = lastChild.getAttribute('data-post-id');
|
||||
if (removedId) renderedPostIds.delete(removedId);
|
||||
divFeed.removeChild(lastChild);
|
||||
}
|
||||
|
||||
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
|
||||
divHint.textContent = followedPubkeys.length > 0
|
||||
? `${posts.length} posts from ${followedPubkeys.length} follows`
|
||||
: 'Follow users to populate your feed.';
|
||||
divFooterRight.textContent = `${posts.length} posts`;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchFeedWindow(authors, { since, limit = FEED_BOOTSTRAP_WINDOW_LIMIT, renderAfterCache = false } = {}) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
const filters = { kinds: [1], authors, limit };
|
||||
@@ -497,7 +569,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
|
||||
upsertFeedPost(evt1);
|
||||
});
|
||||
if (posts.length > 0) {
|
||||
// Only do a full re-render if the feed hasn't been rendered yet.
|
||||
// If the feed is already rendered, the posts are in the array and
|
||||
// will appear when the user clicks "See More" or on next full render.
|
||||
// This prevents the relay fetch callback from wiping prepended live posts.
|
||||
if (posts.length > 0 && renderedPostIds.size === 0) {
|
||||
renderFeed();
|
||||
}
|
||||
}).catch(() => {});
|
||||
@@ -554,6 +630,9 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const since = newestKnown > 0 ? Math.max(0, newestKnown - 30) : (now - 3600);
|
||||
|
||||
// NDK.subscribe already calls outboxTracker.trackUsers internally,
|
||||
// so no explicit warmOutbox call is needed here.
|
||||
|
||||
subscribe(
|
||||
{ kinds: [1], authors, since },
|
||||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||||
@@ -576,11 +655,20 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const allAuthors = Array.from(feedPubkeys);
|
||||
|
||||
if (!hasBootstrappedFeed) {
|
||||
// Pre-warm the outbox tracker so NDK knows each follow's write relays
|
||||
// before the short-lived bootstrap fetchEvents calls fire.
|
||||
try {
|
||||
await warmOutbox(followedPubkeys);
|
||||
} catch (e) {
|
||||
console.warn('[feed.html] warmOutbox failed (non-blocking):', e?.message || e);
|
||||
}
|
||||
await bootstrapFeedPosts(allAuthors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newAuthors.length > 0) {
|
||||
// NDK's fetchEvents/subscribe already calls outboxTracker.trackUsers
|
||||
// internally for author-based filters, so no explicit warmOutbox needed.
|
||||
try {
|
||||
await fetchFeedWindow(newAuthors, { limit: INITIAL_POSTS_LOAD });
|
||||
renderFeed();
|
||||
@@ -639,12 +727,6 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
|
||||
document.getElementById('logoutButton')?.addEventListener('click', logout);
|
||||
|
||||
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
|
||||
const nextVisible = !cards.getCommentsVisible();
|
||||
cards.setCommentsVisible(nextVisible);
|
||||
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
|
||||
});
|
||||
|
||||
btnSeeMore.addEventListener('click', () => {
|
||||
displayCount += SEE_MORE_INCREMENT;
|
||||
renderFeed();
|
||||
@@ -702,7 +784,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
return;
|
||||
}
|
||||
if (!upsertFeedPost(evt)) return;
|
||||
renderFeed();
|
||||
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
|
||||
prependPostCard(evt);
|
||||
} else {
|
||||
renderFeed();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -720,7 +806,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
|
||||
} catch (error) {
|
||||
console.error('[feed.html] Initialization failed:', error);
|
||||
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:red;">❌ Error</div><div style="font-size:16px;">${error.message}</div></div>`;
|
||||
divBody.innerHTML = `<div style="text-align:center;padding:50px;"><div style="font-size:24px;margin-bottom:20px;color:var(--primary-color);">❌ Error</div><div style="font-size:16px;color:var(--muted-color);">${error.message}</div></div>`;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -876,7 +876,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
================================================================ -->
|
||||
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
190
www/js/broadcast-relays.mjs
Normal file
190
www/js/broadcast-relays.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Broadcast Relays Module (NIP-51 kind:10088)
|
||||
*
|
||||
* Thin wrapper around the worker-side broadcast relay state exposed via
|
||||
* init-ndk.mjs. Mirrors the singleton/listener pattern of mute-list.mjs but
|
||||
* delegates all persistence (NIP-44 encryption, kind 10088 publish, skip-mark
|
||||
* coexistence with Amethyst) to the worker.
|
||||
*
|
||||
* Usage:
|
||||
* import {
|
||||
* loadBroadcastRelays, getActiveBroadcastRelays, getSkippedBroadcastRelays,
|
||||
* addBroadcastRelay, removeBroadcastRelay, seedFromDiscovered,
|
||||
* unskipRelay, onBroadcastRelaysChanged
|
||||
* } from './broadcast-relays.mjs';
|
||||
* await loadBroadcastRelays();
|
||||
*/
|
||||
|
||||
import {
|
||||
getBroadcastRelays,
|
||||
saveBroadcastRelays,
|
||||
seedBroadcastRelays,
|
||||
unskipBroadcastRelay,
|
||||
} from './init-ndk.mjs';
|
||||
|
||||
// ── Internal state ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Set of active broadcast relay URLs (kind 10088 "r" tags). */
|
||||
let active = new Set();
|
||||
|
||||
/** Map<url, { reason, timestamp }> of skip-marked relays (kind 10088 "x" tags). */
|
||||
let skipped = new Map();
|
||||
|
||||
/** Change listeners. */
|
||||
const listeners = new Set();
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a relay URL: lowercase host, ensure wss:// or ws:// prefix, strip
|
||||
* trailing slash. Returns null if the input is not a usable URL.
|
||||
*/
|
||||
function normalizeRelayUrl(url) {
|
||||
if (!url) return null;
|
||||
let s = String(url).trim();
|
||||
if (!s) return null;
|
||||
if (!/^wss?:\/\//i.test(s)) {
|
||||
// Default to wss:// when the user typed a bare host.
|
||||
s = 'wss://' + s;
|
||||
}
|
||||
// Split scheme vs rest so we only lowercase the scheme/host portion.
|
||||
const match = s.match(/^(wss?:\/\/)([^/]+)(.*)$/i);
|
||||
if (!match) return null;
|
||||
const scheme = match[1].toLowerCase();
|
||||
const host = match[2].toLowerCase();
|
||||
let path = match[3] || '';
|
||||
// Strip trailing slash but keep root "/" paths intentional.
|
||||
if (path.length > 0 && path.endsWith('/')) {
|
||||
path = path.replace(/\/+$/, '');
|
||||
}
|
||||
return scheme + host + path;
|
||||
}
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch the current broadcast relay state from the worker and refresh local
|
||||
* caches. Safe to call repeatedly (e.g. on sidenav open or cross-tab update).
|
||||
*/
|
||||
export async function loadBroadcastRelays() {
|
||||
try {
|
||||
const result = await getBroadcastRelays();
|
||||
active = new Set(Array.isArray(result?.active) ? result.active : []);
|
||||
const skippedArr = Array.isArray(result?.skipped) ? result.skipped : [];
|
||||
skipped = new Map(skippedArr.map((item) => [item.url, item]));
|
||||
notify();
|
||||
} catch (err) {
|
||||
console.warn('[broadcast-relays] loadBroadcastRelays failed:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return an array of active broadcast relay URLs. */
|
||||
export function getActiveBroadcastRelays() {
|
||||
return Array.from(active);
|
||||
}
|
||||
|
||||
/** Return an array of { url, reason, timestamp } for skip-marked relays. */
|
||||
export function getSkippedBroadcastRelays() {
|
||||
return Array.from(skipped.values());
|
||||
}
|
||||
|
||||
/** Check whether a URL is in the active broadcast set. */
|
||||
export function isBroadcastRelay(url) {
|
||||
if (!url) return false;
|
||||
return active.has(url);
|
||||
}
|
||||
|
||||
// ── Mutate ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a relay URL to the active broadcast set and persist via the worker.
|
||||
* The URL is normalized before being stored.
|
||||
*/
|
||||
export async function addBroadcastRelay(url) {
|
||||
const normalized = normalizeRelayUrl(url);
|
||||
if (!normalized) {
|
||||
console.warn('[broadcast-relays] addBroadcastRelay: invalid url', url);
|
||||
return;
|
||||
}
|
||||
if (active.has(normalized)) return;
|
||||
active.add(normalized);
|
||||
try {
|
||||
await saveBroadcastRelays(Array.from(active));
|
||||
} catch (err) {
|
||||
console.error('[broadcast-relays] addBroadcastRelay save failed:', err?.message || err);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a relay URL from the active broadcast set and persist via the worker.
|
||||
*/
|
||||
export async function removeBroadcastRelay(url) {
|
||||
if (!active.has(url)) return;
|
||||
active.delete(url);
|
||||
try {
|
||||
await saveBroadcastRelays(Array.from(active));
|
||||
} catch (err) {
|
||||
console.error('[broadcast-relays] removeBroadcastRelay save failed:', err?.message || err);
|
||||
}
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click seed: add all discovered relays (from follows' kind 10002 lists)
|
||||
* to the broadcast set. Returns the number of newly added relays.
|
||||
*/
|
||||
export async function seedFromDiscovered() {
|
||||
try {
|
||||
const result = await seedBroadcastRelays();
|
||||
await loadBroadcastRelays(); // refresh from worker
|
||||
return result?.added || 0;
|
||||
} catch (err) {
|
||||
console.error('[broadcast-relays] seedFromDiscovered failed:', err?.message || err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a relay from the skip-mark set so it is included in future publishes.
|
||||
*/
|
||||
export async function unskipRelay(url) {
|
||||
if (!url) return;
|
||||
try {
|
||||
await unskipBroadcastRelay(url);
|
||||
await loadBroadcastRelays();
|
||||
} catch (err) {
|
||||
console.error('[broadcast-relays] unskipRelay failed:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Events ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a callback that fires whenever the active/skipped sets change.
|
||||
* @param {Function} callback
|
||||
* @returns {Function} unsubscribe
|
||||
*/
|
||||
export function onBroadcastRelaysChanged(callback) {
|
||||
if (typeof callback !== 'function') return () => {};
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
const snapshot = {
|
||||
active: Array.from(active),
|
||||
skipped: Array.from(skipped.values()),
|
||||
};
|
||||
for (const cb of listeners) {
|
||||
try {
|
||||
cb(snapshot);
|
||||
} catch (err) {
|
||||
console.warn('[broadcast-relays] Listener error:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[broadcast-relays] Module loaded');
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
walletRemoveMint,
|
||||
walletGetTransactions,
|
||||
walletShutdown,
|
||||
walletRepublish,
|
||||
walletPublishMintList,
|
||||
walletFetchMintList,
|
||||
onWalletStatus,
|
||||
@@ -397,6 +398,16 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
|
||||
}
|
||||
}
|
||||
|
||||
async function republishWallet() {
|
||||
await ensureWallet();
|
||||
const result = await walletRepublish();
|
||||
if (Array.isArray(result?.mints)) {
|
||||
mintsCache = uniqueMints(result.mints);
|
||||
}
|
||||
applyBalancePayload(result || {});
|
||||
return { mints: [...mintsCache] };
|
||||
}
|
||||
|
||||
cleanupFns.push(onWalletStatus((payload) => {
|
||||
if (typeof payload?.hasWallet === 'boolean') {
|
||||
hasWalletCache = payload.hasWallet;
|
||||
@@ -431,6 +442,7 @@ export async function createCashuWalletController({ pubkey, relayUrls = [] } = {
|
||||
waitForDeposit,
|
||||
getTransactionHistory,
|
||||
publishMintList,
|
||||
republishWallet,
|
||||
fetchRecipientMintList,
|
||||
onWalletEvent,
|
||||
subscribeTransactions,
|
||||
|
||||
542
www/js/gruuv-api.mjs
Normal file
542
www/js/gruuv-api.mjs
Normal file
@@ -0,0 +1,542 @@
|
||||
import { ndkFetchEvents, queryCache } from './init-ndk.mjs';
|
||||
|
||||
const TRACK_KIND = 36787;
|
||||
const GRUUV_TOPIC = 'gruuv';
|
||||
const MUSIC_TOPIC = 'music';
|
||||
const DEFAULT_LIMIT = 600;
|
||||
|
||||
function normalizeText(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeLower(value = '') {
|
||||
return normalizeText(value).toLowerCase();
|
||||
}
|
||||
|
||||
function safeNumber(value, fallback = 0) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function getTagValues(tags, key) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags
|
||||
.filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string')
|
||||
.map((t) => t[1]);
|
||||
}
|
||||
|
||||
function getTagValue(tags, key) {
|
||||
const values = getTagValues(tags, key);
|
||||
return values[0] || '';
|
||||
}
|
||||
|
||||
function normalizeSlug(value = '') {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 96);
|
||||
}
|
||||
|
||||
function parseTrackCoordinate(id = '') {
|
||||
const raw = String(id || '').trim();
|
||||
if (!raw.startsWith(`${TRACK_KIND}:`)) return null;
|
||||
const parts = raw.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
const kind = Number(parts[0]);
|
||||
const pubkey = String(parts[1] || '').trim().toLowerCase();
|
||||
const dTag = parts.slice(2).join(':').trim();
|
||||
if (kind !== TRACK_KIND) return null;
|
||||
if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null;
|
||||
return { kind, pubkey, dTag };
|
||||
}
|
||||
|
||||
function isHexEventId(value = '') {
|
||||
return /^[a-f0-9]{64}$/i.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function inferFormatFromMime(mime = '') {
|
||||
const m = String(mime || '').toLowerCase();
|
||||
if (m.includes('flac')) return 'flac';
|
||||
if (m.includes('mpeg') || m.includes('mp3')) return 'mp3';
|
||||
if (m.includes('aac')) return 'aac';
|
||||
if (m.includes('ogg')) return 'ogg';
|
||||
if (m.includes('wav')) return 'wav';
|
||||
return '';
|
||||
}
|
||||
|
||||
function hasTopic(tags, topic) {
|
||||
const lower = normalizeLower(topic);
|
||||
return getTagValues(tags, 't').some((v) => normalizeLower(v) === lower);
|
||||
}
|
||||
|
||||
function buildTrackIdFromEvent(event) {
|
||||
const pubkey = normalizeLower(event?.pubkey || '');
|
||||
const dTag = normalizeText(getTagValue(event?.tags, 'd'));
|
||||
if (pubkey && dTag) return `${TRACK_KIND}:${pubkey}:${dTag}`;
|
||||
return normalizeText(event?.id || '');
|
||||
}
|
||||
|
||||
function parseReleasedYear(released = '') {
|
||||
const match = String(released || '').match(/(\d{4})/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
}
|
||||
|
||||
function sortByRecent(a, b) {
|
||||
return safeNumber(b?.created_at, 0) - safeNumber(a?.created_at, 0);
|
||||
}
|
||||
|
||||
function dedupeEvents(events, { requireTopic = true } = {}) {
|
||||
const byAddress = new Map();
|
||||
const byEventId = new Map();
|
||||
|
||||
for (const event of events || []) {
|
||||
if (!event || typeof event !== 'object') continue;
|
||||
if (event.kind !== TRACK_KIND || !Array.isArray(event.tags)) continue;
|
||||
if (requireTopic && !(hasTopic(event.tags, GRUUV_TOPIC) || hasTopic(event.tags, MUSIC_TOPIC))) continue;
|
||||
|
||||
const eventId = normalizeLower(event.id || '');
|
||||
if (eventId && byEventId.has(eventId)) continue;
|
||||
|
||||
const address = buildTrackIdFromEvent(event);
|
||||
const existing = byAddress.get(address);
|
||||
if (!existing || safeNumber(event.created_at, 0) >= safeNumber(existing.created_at, 0)) {
|
||||
byAddress.set(address, event);
|
||||
if (eventId) byEventId.set(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byAddress.values()].sort(sortByRecent);
|
||||
}
|
||||
|
||||
function eventToNormalizedTrack(event) {
|
||||
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||
const id = buildTrackIdFromEvent(event);
|
||||
const title = normalizeText(getTagValue(tags, 'title')) || 'Unknown title';
|
||||
const artist = normalizeText(getTagValue(tags, 'artist')) || 'Unknown artist';
|
||||
const albumTitle = normalizeText(getTagValue(tags, 'album'));
|
||||
const duration = safeNumber(getTagValue(tags, 'duration'), 0);
|
||||
const cover = normalizeText(getTagValue(tags, 'image'));
|
||||
const streamUrl = normalizeText(getTagValue(tags, 'url'));
|
||||
const format = normalizeText(getTagValue(tags, 'format')) || inferFormatFromMime(getTagValue(tags, 'm'));
|
||||
const mime = normalizeText(getTagValue(tags, 'm'));
|
||||
const sha256 = normalizeText(getTagValue(tags, 'x'));
|
||||
const released = normalizeText(getTagValue(tags, 'released'));
|
||||
const trackNumber = normalizeText(getTagValue(tags, 'track_number') || getTagValue(tags, 'track'));
|
||||
const discNumber = normalizeText(getTagValue(tags, 'disc_number') || getTagValue(tags, 'disc'));
|
||||
const genre = normalizeText(getTagValue(tags, 'genre'));
|
||||
|
||||
const albumId = albumTitle ? `album:${normalizeSlug(`${artist} ${albumTitle}`)}` : '';
|
||||
const artistId = artist ? `artist:${normalizeSlug(artist)}` : '';
|
||||
|
||||
return {
|
||||
id,
|
||||
eventId: normalizeText(event?.id || ''),
|
||||
pubkey: normalizeText(event?.pubkey || ''),
|
||||
dTag: normalizeText(getTagValue(tags, 'd')),
|
||||
title,
|
||||
artist,
|
||||
artistId,
|
||||
duration,
|
||||
cover,
|
||||
albumTitle,
|
||||
albumId,
|
||||
streamUrl,
|
||||
format,
|
||||
mime,
|
||||
sha256,
|
||||
size: safeNumber(getTagValue(tags, 'size'), 0),
|
||||
released,
|
||||
releaseYear: parseReleasedYear(released),
|
||||
genre,
|
||||
trackNumber,
|
||||
discNumber,
|
||||
createdAt: safeNumber(event?.created_at, 0),
|
||||
raw: event,
|
||||
};
|
||||
}
|
||||
|
||||
function shapeSearchResponse(items) {
|
||||
const rows = Array.isArray(items) ? items : [];
|
||||
return {
|
||||
items: rows,
|
||||
limit: rows.length,
|
||||
offset: 0,
|
||||
totalNumberOfItems: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
function trackMatchesQuery(track, query) {
|
||||
const q = normalizeLower(query);
|
||||
if (!q) return true;
|
||||
|
||||
const haystacks = [
|
||||
track?.title,
|
||||
track?.artist,
|
||||
track?.albumTitle,
|
||||
track?.id,
|
||||
track?.eventId,
|
||||
track?.dTag,
|
||||
track?.genre,
|
||||
]
|
||||
.map((v) => normalizeLower(v))
|
||||
.filter(Boolean);
|
||||
|
||||
return haystacks.some((v) => v.includes(q));
|
||||
}
|
||||
|
||||
function groupTracksByAlbum(tracks) {
|
||||
const byAlbum = new Map();
|
||||
|
||||
for (const track of tracks || []) {
|
||||
const title = normalizeText(track?.albumTitle);
|
||||
if (!title) continue;
|
||||
|
||||
const artist = normalizeText(track?.artist || 'Unknown artist');
|
||||
const key = `${normalizeLower(artist)}::${normalizeLower(title)}`;
|
||||
|
||||
if (!byAlbum.has(key)) {
|
||||
byAlbum.set(key, {
|
||||
id: track.albumId || `album:${normalizeSlug(`${artist} ${title}`)}`,
|
||||
title,
|
||||
cover: normalizeText(track?.cover || ''),
|
||||
artist: { name: artist, id: track.artistId || `artist:${normalizeSlug(artist)}` },
|
||||
artistName: artist,
|
||||
releaseDate: track.released || '',
|
||||
numberOfTracks: 0,
|
||||
explicit: false,
|
||||
type: 'ALBUM',
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
|
||||
const album = byAlbum.get(key);
|
||||
album.tracks.push(track);
|
||||
album.numberOfTracks = album.tracks.length;
|
||||
if (!album.cover && track.cover) album.cover = track.cover;
|
||||
if (!album.releaseDate && track.released) album.releaseDate = track.released;
|
||||
}
|
||||
|
||||
return [...byAlbum.values()].sort((a, b) => {
|
||||
const yearDiff = parseReleasedYear(b.releaseDate) - parseReleasedYear(a.releaseDate);
|
||||
if (yearDiff !== 0) return yearDiff;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
}
|
||||
|
||||
function groupTracksByArtist(tracks) {
|
||||
const byArtist = new Map();
|
||||
|
||||
for (const track of tracks || []) {
|
||||
const name = normalizeText(track?.artist);
|
||||
if (!name) continue;
|
||||
|
||||
const key = normalizeLower(name);
|
||||
if (!byArtist.has(key)) {
|
||||
byArtist.set(key, {
|
||||
id: track.artistId || `artist:${normalizeSlug(name)}`,
|
||||
name,
|
||||
picture: normalizeText(track?.cover || ''),
|
||||
tracks: [],
|
||||
});
|
||||
}
|
||||
|
||||
const artist = byArtist.get(key);
|
||||
artist.tracks.push(track);
|
||||
if (!artist.picture && track.cover) artist.picture = track.cover;
|
||||
}
|
||||
|
||||
return [...byArtist.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function safeFetchWithFilter(filters, options = {}) {
|
||||
const [cacheResult, relayResult] = await Promise.allSettled([
|
||||
queryCache(filters),
|
||||
ndkFetchEvents(filters),
|
||||
]);
|
||||
|
||||
const cacheEvents = cacheResult.status === 'fulfilled' && Array.isArray(cacheResult.value)
|
||||
? cacheResult.value
|
||||
: [];
|
||||
const relayEvents = relayResult.status === 'fulfilled' && Array.isArray(relayResult.value)
|
||||
? relayResult.value
|
||||
: [];
|
||||
|
||||
return dedupeEvents([...cacheEvents, ...relayEvents], options);
|
||||
}
|
||||
|
||||
export class GruuvAPI {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
limit: Number.isFinite(options.limit) ? Number(options.limit) : DEFAULT_LIMIT,
|
||||
...options,
|
||||
};
|
||||
this.trackCache = new Map();
|
||||
this.lastTrackSnapshot = [];
|
||||
}
|
||||
|
||||
async initInstances() {
|
||||
return {
|
||||
api: ['nostr:kind36787'],
|
||||
streaming: ['blossom:url-tag'],
|
||||
};
|
||||
}
|
||||
|
||||
cacheTracks(tracks) {
|
||||
const rows = Array.isArray(tracks) ? tracks : [];
|
||||
for (const track of rows) {
|
||||
if (!track?.id) continue;
|
||||
this.trackCache.set(String(track.id), track);
|
||||
if (track.eventId) this.trackCache.set(String(track.eventId), track);
|
||||
}
|
||||
this.lastTrackSnapshot = rows;
|
||||
}
|
||||
|
||||
async fetchTrackEvents(filters = {}) {
|
||||
const mergedFilter = {
|
||||
kinds: [TRACK_KIND],
|
||||
'#t': [GRUUV_TOPIC],
|
||||
limit: this.options.limit,
|
||||
...filters,
|
||||
};
|
||||
|
||||
const events = await safeFetchWithFilter(mergedFilter);
|
||||
const tracks = events.map(eventToNormalizedTrack).filter((track) => track.streamUrl);
|
||||
this.cacheTracks(tracks);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
prepareTrack(track) {
|
||||
if (!track || typeof track !== 'object') return null;
|
||||
|
||||
if (track.raw && Array.isArray(track.raw.tags)) {
|
||||
return eventToNormalizedTrack(track.raw);
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
id: normalizeText(track.id || ''),
|
||||
title: normalizeText(track.title || '') || 'Unknown title',
|
||||
artist: normalizeText(track.artist || '') || 'Unknown artist',
|
||||
duration: safeNumber(track.duration, 0),
|
||||
cover: normalizeText(track.cover || ''),
|
||||
albumTitle: normalizeText(track.albumTitle || ''),
|
||||
raw: track.raw || track,
|
||||
};
|
||||
|
||||
if (!normalized.id && track.raw?.pubkey && Array.isArray(track.raw?.tags)) {
|
||||
normalized.id = buildTrackIdFromEvent(track.raw);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
getCoverUrl(coverId) {
|
||||
return normalizeText(coverId);
|
||||
}
|
||||
|
||||
getArtistPictureUrl(id) {
|
||||
return normalizeText(id);
|
||||
}
|
||||
|
||||
async searchTracks(query) {
|
||||
const q = normalizeText(query);
|
||||
if (!q) return shapeSearchResponse([]);
|
||||
|
||||
const asCoordinate = parseTrackCoordinate(q);
|
||||
if (asCoordinate) {
|
||||
const tracks = await this.fetchTrackEvents({
|
||||
authors: [asCoordinate.pubkey],
|
||||
'#d': [asCoordinate.dTag],
|
||||
limit: 20,
|
||||
});
|
||||
return shapeSearchResponse(tracks.filter((t) => String(t.id) === q));
|
||||
}
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const filtered = tracks.filter((track) => trackMatchesQuery(track, q));
|
||||
return shapeSearchResponse(filtered);
|
||||
}
|
||||
|
||||
async searchAlbums(query) {
|
||||
const q = normalizeText(query);
|
||||
if (!q) return shapeSearchResponse([]);
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const albums = groupTracksByAlbum(tracks).filter((album) => {
|
||||
const haystack = `${album.title} ${album.artistName}`.toLowerCase();
|
||||
return haystack.includes(normalizeLower(q));
|
||||
});
|
||||
|
||||
return shapeSearchResponse(albums);
|
||||
}
|
||||
|
||||
async searchArtists(query) {
|
||||
const q = normalizeText(query);
|
||||
if (!q) return shapeSearchResponse([]);
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const artists = groupTracksByArtist(tracks).filter((artist) => {
|
||||
const haystack = `${artist.name}`.toLowerCase();
|
||||
return haystack.includes(normalizeLower(q));
|
||||
});
|
||||
|
||||
return shapeSearchResponse(artists);
|
||||
}
|
||||
|
||||
async getAlbum(id) {
|
||||
const albumId = normalizeText(id);
|
||||
if (!albumId) throw new Error('Album id is required');
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const albums = groupTracksByAlbum(tracks);
|
||||
const album = albums.find((a) => String(a.id) === albumId);
|
||||
|
||||
if (!album) throw new Error('Album not found');
|
||||
|
||||
const sortedTracks = [...album.tracks].sort((a, b) => {
|
||||
const aDisc = safeNumber(a.discNumber, 1);
|
||||
const bDisc = safeNumber(b.discNumber, 1);
|
||||
if (aDisc !== bDisc) return aDisc - bDisc;
|
||||
const aTrack = safeNumber(a.trackNumber, Number.MAX_SAFE_INTEGER);
|
||||
const bTrack = safeNumber(b.trackNumber, Number.MAX_SAFE_INTEGER);
|
||||
if (aTrack !== bTrack) return aTrack - bTrack;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
return { album: { ...album, tracks: undefined }, tracks: sortedTracks };
|
||||
}
|
||||
|
||||
async getArtist(artistId) {
|
||||
const id = normalizeText(artistId);
|
||||
if (!id) throw new Error('Artist id is required');
|
||||
|
||||
const tracks = await this.fetchTrackEvents();
|
||||
const artists = groupTracksByArtist(tracks);
|
||||
const artist = artists.find((a) => String(a.id) === id);
|
||||
|
||||
if (!artist) throw new Error('Artist not found');
|
||||
|
||||
const artistTracks = [...artist.tracks].sort((a, b) => {
|
||||
const yearDiff = safeNumber(b.releaseYear, 0) - safeNumber(a.releaseYear, 0);
|
||||
if (yearDiff !== 0) return yearDiff;
|
||||
return a.title.localeCompare(b.title);
|
||||
});
|
||||
|
||||
const albums = groupTracksByAlbum(artistTracks)
|
||||
.map((album) => ({ ...album, tracks: undefined }))
|
||||
.filter((album) => normalizeLower(album.artistName) === normalizeLower(artist.name));
|
||||
|
||||
return {
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
picture: artist.picture,
|
||||
albums,
|
||||
eps: [],
|
||||
tracks: artistTracks.slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
async getTracksByAddresses(addresses = []) {
|
||||
const input = Array.isArray(addresses) ? addresses : [];
|
||||
const normalizedAddresses = [...new Set(
|
||||
input
|
||||
.map((value) => parseTrackCoordinate(value))
|
||||
.filter(Boolean)
|
||||
.map((coord) => `${TRACK_KIND}:${coord.pubkey}:${coord.dTag}`)
|
||||
)];
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:start', {
|
||||
requested: input.length,
|
||||
normalized: normalizedAddresses.length,
|
||||
normalizedAddresses,
|
||||
});
|
||||
|
||||
const byAddress = new Map();
|
||||
const unresolvedByAuthor = new Map();
|
||||
|
||||
for (const address of normalizedAddresses) {
|
||||
const cached = this.trackCache.get(address);
|
||||
if (cached?.streamUrl) {
|
||||
byAddress.set(address, cached);
|
||||
continue;
|
||||
}
|
||||
const coord = parseTrackCoordinate(address);
|
||||
if (!coord) continue;
|
||||
const set = unresolvedByAuthor.get(coord.pubkey) || new Set();
|
||||
set.add(coord.dTag);
|
||||
unresolvedByAuthor.set(coord.pubkey, set);
|
||||
}
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:cache', {
|
||||
cacheHits: byAddress.size,
|
||||
unresolvedAuthors: unresolvedByAuthor.size,
|
||||
});
|
||||
|
||||
for (const [author, dTagsSet] of unresolvedByAuthor.entries()) {
|
||||
const dTags = [...dTagsSet].filter(Boolean);
|
||||
if (!dTags.length) continue;
|
||||
|
||||
const filter = {
|
||||
kinds: [TRACK_KIND],
|
||||
authors: [author],
|
||||
'#d': dTags,
|
||||
limit: Math.max(120, dTags.length * 3),
|
||||
};
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:fetch', {
|
||||
author,
|
||||
dTags,
|
||||
filter,
|
||||
});
|
||||
|
||||
const events = await safeFetchWithFilter(filter, { requireTopic: false });
|
||||
console.log('[gruuv-api] getTracksByAddresses:events', {
|
||||
author,
|
||||
dTagsRequested: dTags.length,
|
||||
eventsFetched: events.length,
|
||||
eventIds: events.map((event) => event?.id).filter(Boolean),
|
||||
});
|
||||
|
||||
const tracks = events
|
||||
.map(eventToNormalizedTrack)
|
||||
.filter((track) => track.streamUrl);
|
||||
|
||||
this.cacheTracks(tracks);
|
||||
for (const track of tracks) {
|
||||
if (!track?.id) continue;
|
||||
byAddress.set(String(track.id), track);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[gruuv-api] getTracksByAddresses:done', {
|
||||
resolved: byAddress.size,
|
||||
resolvedIds: [...byAddress.keys()],
|
||||
});
|
||||
|
||||
return byAddress;
|
||||
}
|
||||
|
||||
async getTrackStream(id) {
|
||||
const target = normalizeText(id);
|
||||
if (!target) throw new Error('Track id is required');
|
||||
|
||||
let track = this.trackCache.get(target) || null;
|
||||
|
||||
if (!track && parseTrackCoordinate(target)) {
|
||||
const lookup = await this.searchTracks(target);
|
||||
const items = Array.isArray(lookup?.items) ? lookup.items : [];
|
||||
track = items.find((item) => String(item?.id) === target) || items[0] || null;
|
||||
}
|
||||
|
||||
if (!track && isHexEventId(target)) {
|
||||
const tracks = await this.fetchTrackEvents({ ids: [target], limit: 20 });
|
||||
track = tracks.find((item) => String(item.eventId) === target) || null;
|
||||
}
|
||||
|
||||
if (!track) throw new Error('Track not found');
|
||||
if (!track.streamUrl) throw new Error('Track has no stream URL');
|
||||
|
||||
return { streamUrl: track.streamUrl, isDash: false };
|
||||
}
|
||||
}
|
||||
199
www/js/gruuv-playlists.mjs
Normal file
199
www/js/gruuv-playlists.mjs
Normal file
@@ -0,0 +1,199 @@
|
||||
const PLAYLIST_KIND = 34139;
|
||||
const TRACK_KIND = 36787;
|
||||
|
||||
function getTagValue(tags, key) {
|
||||
if (!Array.isArray(tags)) return '';
|
||||
const tag = tags.find((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string');
|
||||
return tag?.[1] || '';
|
||||
}
|
||||
|
||||
function getTagValues(tags, key) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags
|
||||
.filter((t) => Array.isArray(t) && t[0] === key && typeof t[1] === 'string')
|
||||
.map((t) => t[1]);
|
||||
}
|
||||
|
||||
function normalizeSlug(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function parseTrackAddress(address = '') {
|
||||
const raw = String(address || '').trim();
|
||||
const parts = raw.split(':');
|
||||
if (parts.length < 3) return null;
|
||||
if (Number(parts[0]) !== TRACK_KIND) return null;
|
||||
const pubkey = String(parts[1] || '').trim().toLowerCase();
|
||||
const dTag = parts.slice(2).join(':').trim();
|
||||
if (!/^[a-f0-9]{64}$/i.test(pubkey) || !dTag) return null;
|
||||
return {
|
||||
kind: TRACK_KIND,
|
||||
pubkey,
|
||||
dTag,
|
||||
address: `${TRACK_KIND}:${pubkey}:${dTag}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrackTag(track) {
|
||||
const id = String(track?.id || '').trim();
|
||||
const parsed = parseTrackAddress(id);
|
||||
if (parsed) return ['a', parsed.address];
|
||||
|
||||
const rawPubkey = String(track?.raw?.pubkey || track?.pubkey || '').trim().toLowerCase();
|
||||
const dTag = String(
|
||||
track?.dTag
|
||||
|| getTagValue(track?.raw?.tags, 'd')
|
||||
|| track?.identifier
|
||||
|| ''
|
||||
).trim();
|
||||
|
||||
if (/^[a-f0-9]{64}$/i.test(rawPubkey) && dTag) {
|
||||
return ['a', `${TRACK_KIND}:${rawPubkey}:${dTag}`];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTrackCache(track) {
|
||||
const id = String(track?.id || '').trim();
|
||||
if (!id) return null;
|
||||
|
||||
return {
|
||||
id,
|
||||
title: typeof track?.title === 'string' ? track.title : '',
|
||||
artist: typeof track?.artist === 'string' ? track.artist : '',
|
||||
duration: Number.isFinite(track?.duration) ? Number(track.duration) : 0,
|
||||
cover: typeof track?.cover === 'string' ? track.cover : '',
|
||||
albumTitle: typeof track?.albumTitle === 'string' ? track.albumTitle : '',
|
||||
streamUrl: typeof track?.streamUrl === 'string' ? track.streamUrl : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlaylistIdentifier(title = '') {
|
||||
const slug = normalizeSlug(title);
|
||||
if (!slug) return `${Date.now()}`;
|
||||
return `${slug}-${Date.now()}`;
|
||||
}
|
||||
|
||||
export function isMusicPlaylistEvent(event) {
|
||||
if (!event || event.kind !== PLAYLIST_KIND || !Array.isArray(event.tags)) return false;
|
||||
|
||||
const topics = getTagValues(event.tags, 't').map((t) => t.toLowerCase());
|
||||
if (topics.includes('gruuv') || topics.includes('music') || topics.includes('playlist')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasIdentifier = Boolean(getTagValue(event.tags, 'd'));
|
||||
const hasTitle = Boolean(getTagValue(event.tags, 'title'));
|
||||
const hasTrackRefs = getTagValues(event.tags, 'a').some((value) => Boolean(parseTrackAddress(value)));
|
||||
|
||||
return hasIdentifier && (hasTitle || hasTrackRefs);
|
||||
}
|
||||
|
||||
export function buildPlaylistEvent(playlist, createdAt = Math.floor(Date.now() / 1000)) {
|
||||
const identifier = playlist?.identifier || playlist?.id || createPlaylistIdentifier(playlist?.title || '');
|
||||
const title = typeof playlist?.title === 'string' ? playlist.title.trim() : '';
|
||||
const description = typeof playlist?.description === 'string' ? playlist.description.trim() : '';
|
||||
const image = typeof playlist?.image === 'string' ? playlist.image.trim() : '';
|
||||
const banner = typeof playlist?.banner === 'string' ? playlist.banner.trim() : '';
|
||||
|
||||
const inputTracks = Array.isArray(playlist?.tracks) ? playlist.tracks : [];
|
||||
const cacheTracks = inputTracks.map(buildTrackCache).filter(Boolean);
|
||||
|
||||
const seen = new Set();
|
||||
const trackTags = [];
|
||||
|
||||
for (const track of inputTracks) {
|
||||
const tag = buildTrackTag(track);
|
||||
if (!tag) continue;
|
||||
const value = tag[1];
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
trackTags.push(tag);
|
||||
}
|
||||
|
||||
const tags = [
|
||||
['d', identifier],
|
||||
['title', title || 'Untitled playlist'],
|
||||
['alt', `Playlist: ${title || 'Untitled playlist'}`],
|
||||
['t', 'gruuv'],
|
||||
['t', 'music'],
|
||||
['t', 'playlist'],
|
||||
];
|
||||
|
||||
if (description) tags.push(['description', description]);
|
||||
if (image) tags.push(['image', image]);
|
||||
if (banner) tags.push(['banner', banner]);
|
||||
tags.push(...trackTags);
|
||||
|
||||
return {
|
||||
created_at: createdAt,
|
||||
kind: PLAYLIST_KIND,
|
||||
tags,
|
||||
content: JSON.stringify({ tracks: cacheTracks }),
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePlaylistEvent(event) {
|
||||
if (!event || event.kind !== PLAYLIST_KIND) return null;
|
||||
if (!Array.isArray(event.tags)) return null;
|
||||
|
||||
const identifier = getTagValue(event.tags, 'd') || '';
|
||||
const title = getTagValue(event.tags, 'title') || 'Untitled playlist';
|
||||
const description = getTagValue(event.tags, 'description') || '';
|
||||
const image = getTagValue(event.tags, 'image') || '';
|
||||
const banner = getTagValue(event.tags, 'banner') || '';
|
||||
|
||||
const aValues = getTagValues(event.tags, 'a');
|
||||
const trackIdsFromTags = aValues
|
||||
.map((v) => parseTrackAddress(v)?.address || '')
|
||||
.filter(Boolean);
|
||||
|
||||
const parsedContent = safeJsonParse(event.content || '', {});
|
||||
const cacheTracks = Array.isArray(parsedContent?.tracks)
|
||||
? parsedContent.tracks.map(buildTrackCache).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const cachedById = new Map(cacheTracks.map((t) => [String(t.id), t]));
|
||||
const trackIds = [...new Set(trackIdsFromTags)];
|
||||
|
||||
const tracks = trackIds.map((id) => {
|
||||
const cached = cachedById.get(id);
|
||||
if (cached) return cached;
|
||||
return { id, title: '', artist: '', duration: 0, cover: '', albumTitle: '', streamUrl: '' };
|
||||
});
|
||||
|
||||
return {
|
||||
eventId: event.id || '',
|
||||
pubkey: event.pubkey || '',
|
||||
createdAt: Number(event.created_at) || 0,
|
||||
identifier,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
banner,
|
||||
tracks,
|
||||
trackIds,
|
||||
raw: event,
|
||||
};
|
||||
}
|
||||
|
||||
export function playlistEventAddress(pubkey, identifier) {
|
||||
if (!pubkey || !identifier) return '';
|
||||
return `${PLAYLIST_KIND}:${pubkey}:${identifier}`;
|
||||
}
|
||||
|
||||
export { PLAYLIST_KIND, parseTrackAddress, buildTrackTag };
|
||||
204
www/js/gruuv-upload.mjs
Normal file
204
www/js/gruuv-upload.mjs
Normal file
@@ -0,0 +1,204 @@
|
||||
import { publishEvent, getPubkey } from './init-ndk.mjs';
|
||||
import { calculateSHA256, createBlossomAuth } from './blossom-api.mjs';
|
||||
import { getBlossomServers } from './blossom-ui.mjs';
|
||||
|
||||
const TRACK_KIND = 36787;
|
||||
|
||||
function normalizeText(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function extensionFromName(name = '') {
|
||||
const base = String(name || '').trim();
|
||||
const idx = base.lastIndexOf('.');
|
||||
if (idx < 0) return '';
|
||||
return base.slice(idx + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferFormat(file) {
|
||||
const ext = extensionFromName(file?.name || '');
|
||||
if (ext) return ext;
|
||||
|
||||
const mime = normalizeText(file?.type || '').toLowerCase();
|
||||
if (mime.includes('flac')) return 'flac';
|
||||
if (mime.includes('mpeg') || mime.includes('mp3')) return 'mp3';
|
||||
if (mime.includes('aac')) return 'aac';
|
||||
if (mime.includes('ogg')) return 'ogg';
|
||||
if (mime.includes('wav')) return 'wav';
|
||||
return 'bin';
|
||||
}
|
||||
|
||||
function inferMime(file) {
|
||||
const mime = normalizeText(file?.type || '');
|
||||
if (mime) return mime;
|
||||
|
||||
const format = inferFormat(file);
|
||||
if (format === 'mp3') return 'audio/mpeg';
|
||||
if (format === 'flac') return 'audio/flac';
|
||||
if (format === 'ogg') return 'audio/ogg';
|
||||
if (format === 'aac') return 'audio/aac';
|
||||
if (format === 'wav') return 'audio/wav';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function selectUploadServer() {
|
||||
const servers = getBlossomServers();
|
||||
const healthyUpload = servers.find((s) => s?.healthy && s?.upload);
|
||||
if (healthyUpload?.url) return String(healthyUpload.url).trim().replace(/\/$/, '');
|
||||
|
||||
const anyUpload = servers.find((s) => s?.upload);
|
||||
if (anyUpload?.url) return String(anyUpload.url).trim().replace(/\/$/, '');
|
||||
|
||||
const healthyAny = servers.find((s) => s?.healthy);
|
||||
if (healthyAny?.url) return String(healthyAny.url).trim().replace(/\/$/, '');
|
||||
|
||||
if (servers[0]?.url) return String(servers[0].url).trim().replace(/\/$/, '');
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getAudioDurationSeconds(file) {
|
||||
if (!(file instanceof File || file instanceof Blob)) return 0;
|
||||
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const duration = await new Promise((resolve) => {
|
||||
const audio = document.createElement('audio');
|
||||
const cleanup = () => {
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
};
|
||||
|
||||
audio.preload = 'metadata';
|
||||
audio.onloadedmetadata = () => {
|
||||
const d = Number(audio.duration);
|
||||
cleanup();
|
||||
resolve(Number.isFinite(d) && d > 0 ? d : 0);
|
||||
};
|
||||
audio.onerror = () => {
|
||||
cleanup();
|
||||
resolve(0);
|
||||
};
|
||||
audio.src = objectUrl;
|
||||
});
|
||||
|
||||
return Math.round(duration);
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function makeDTag({ title, artist, sha256 }) {
|
||||
const safeTitle = normalizeText(title)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
const safeArtist = normalizeText(artist)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 32);
|
||||
|
||||
const prefix = [safeArtist, safeTitle].filter(Boolean).join('-') || 'track';
|
||||
return `${prefix}-${String(sha256 || '').slice(0, 8)}-${Date.now()}`;
|
||||
}
|
||||
|
||||
async function uploadFileToBlossom(file, serverUrl, sha256) {
|
||||
const authToken = await createBlossomAuth('upload', sha256, `Upload ${file.name || 'audio'}`);
|
||||
const res = await fetch(`${serverUrl}/upload`, {
|
||||
method: 'PUT',
|
||||
mode: 'cors',
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Content-Type': inferMime(file),
|
||||
Authorization: `Nostr ${authToken}`,
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const url = normalizeText(payload?.url || payload?.blob?.url || '');
|
||||
return {
|
||||
uploadUrl: url || `${serverUrl}/${sha256}`,
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadGruuvTrack(file, metadata = {}, options = {}) {
|
||||
if (!file) throw new Error('No file selected');
|
||||
|
||||
const pubkey = await getPubkey();
|
||||
if (!pubkey) throw new Error('Not authenticated');
|
||||
|
||||
const serverUrl = normalizeText(options.serverUrl || selectUploadServer());
|
||||
if (!serverUrl) throw new Error('No Blossom server available for upload');
|
||||
|
||||
const sha256 = await calculateSHA256(file);
|
||||
const uploadResult = await uploadFileToBlossom(file, serverUrl, sha256);
|
||||
|
||||
const title = normalizeText(metadata.title || file.name?.replace(/\.[^.]+$/, '') || 'Untitled');
|
||||
const artist = normalizeText(metadata.artist || 'Unknown artist');
|
||||
const album = normalizeText(metadata.album || 'Uploads');
|
||||
const image = normalizeText(metadata.image || '');
|
||||
const genre = normalizeText(metadata.genre || '');
|
||||
const released = normalizeText(metadata.released || '');
|
||||
const trackNumber = normalizeText(metadata.trackNumber || '');
|
||||
const discNumber = normalizeText(metadata.discNumber || '');
|
||||
|
||||
const duration = Number.isFinite(metadata.duration)
|
||||
? Number(metadata.duration)
|
||||
: await getAudioDurationSeconds(file);
|
||||
|
||||
const format = inferFormat(file);
|
||||
const mime = inferMime(file);
|
||||
const dTag = normalizeText(metadata.dTag || makeDTag({ title, artist, sha256 }));
|
||||
|
||||
const tags = [
|
||||
['d', dTag],
|
||||
['url', uploadResult.uploadUrl],
|
||||
['title', title],
|
||||
['artist', artist],
|
||||
['album', album],
|
||||
['duration', String(Math.max(0, Math.round(duration || 0)))],
|
||||
['size', String(file.size || 0)],
|
||||
['alt', `Song: ${title} - ${artist}`],
|
||||
['format', format],
|
||||
['m', mime],
|
||||
['x', sha256],
|
||||
['t', 'music'],
|
||||
['t', 'gruuv'],
|
||||
];
|
||||
|
||||
if (image) tags.push(['image', image]);
|
||||
if (genre) {
|
||||
tags.push(['genre', genre]);
|
||||
tags.push(['t', genre.toLowerCase()]);
|
||||
}
|
||||
if (released) tags.push(['released', released]);
|
||||
if (trackNumber) tags.push(['track_number', trackNumber]);
|
||||
if (discNumber && discNumber !== '1') tags.push(['disc_number', discNumber]);
|
||||
|
||||
const event = {
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: TRACK_KIND,
|
||||
tags,
|
||||
content: `Listen to my song - ${title} by ${artist}`,
|
||||
};
|
||||
|
||||
const publishResult = await publishEvent(event);
|
||||
|
||||
return {
|
||||
file,
|
||||
pubkey,
|
||||
sha256,
|
||||
url: uploadResult.uploadUrl,
|
||||
dTag,
|
||||
event,
|
||||
publishResult,
|
||||
};
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export async function initNDKPage(options = {}) {
|
||||
|
||||
// All pages in www/ directory, so worker path is always the same.
|
||||
// Include explicit worker revision token so updated SharedWorker code is guaranteed to restart.
|
||||
const WORKER_REVISION = 'relay-events-db-6';
|
||||
const WORKER_REVISION = 'nip60-lightweight-republish-1';
|
||||
const workerPath = `./ndk-worker.js?v=${encodeURIComponent(VERSION)}&wr=${encodeURIComponent(WORKER_REVISION)}`;
|
||||
|
||||
// Initialize NDK SharedWorker (shared across all tabs/pages)
|
||||
@@ -274,7 +274,8 @@ async function ensureAuthenticated() {
|
||||
extension: true,
|
||||
local: true,
|
||||
nip46: true,
|
||||
seedphrase: true
|
||||
seedphrase: true,
|
||||
nsigner: true
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -372,6 +373,30 @@ function handleWorkerMessage(event) {
|
||||
window.dispatchEvent(new CustomEvent('ndkRelays', { detail: message.data }));
|
||||
} else if (message.type === 'muteListUpdated') {
|
||||
window.dispatchEvent(new CustomEvent('ndkMuteListUpdated', { detail: message.data || {} }));
|
||||
} else if (message.type === 'broadcastRelaysUpdated') {
|
||||
// Broadcast relay list (kind 10088) changed — either from a save in
|
||||
// this tab, a live subscription update, or a cross-tab edit. Pages
|
||||
// (e.g. relays.html sidenav) re-render on this event.
|
||||
window.dispatchEvent(new CustomEvent('ndkBroadcastRelaysUpdated'));
|
||||
} else if (message.type === 'broadcastProgress') {
|
||||
// Live per-relay publish progress from handlePublish. Pass through the
|
||||
// full payload so footers can show "📡 42/150 relays · relay.example.com".
|
||||
window.dispatchEvent(new CustomEvent('ndkBroadcastProgress', {
|
||||
detail: {
|
||||
sessionId: message.sessionId,
|
||||
phase: message.phase,
|
||||
eventKind: message.eventKind,
|
||||
eventId: message.eventId,
|
||||
total: message.total,
|
||||
successful: message.successful,
|
||||
failed: message.failed,
|
||||
latestRelay: message.latestRelay,
|
||||
latestError: message.latestError,
|
||||
relayUrls: message.relayUrls || [],
|
||||
batchCurrent: message.batchCurrent,
|
||||
batchTotal: message.batchTotal,
|
||||
}
|
||||
}));
|
||||
} else if (message.type === 'startupStatus') {
|
||||
window.dispatchEvent(new CustomEvent('ndkStartupStatus', { detail: message.data || {} }));
|
||||
} else if (message.type === 'relayEvent') {
|
||||
@@ -432,6 +457,37 @@ function handleWorkerMessage(event) {
|
||||
pending.resolve(message.events || []);
|
||||
}
|
||||
}
|
||||
} else if (message.type === 'warmOutboxResult') {
|
||||
// Handle warmOutbox response — best-effort pre-warm; always resolves.
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(message.requestId);
|
||||
pending.resolve({
|
||||
ok: !!message.ok,
|
||||
count: message.count || 0,
|
||||
skipped: !!message.skipped,
|
||||
error: message.error || null
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'setRelayEventLoggingResult') {
|
||||
// Handle setRelayEventLogging response — fire-and-forget, but resolve
|
||||
// the promise if the caller chose to await it.
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(message.requestId);
|
||||
pending.resolve({
|
||||
ok: !!message.ok,
|
||||
enabled: !!message.enabled,
|
||||
error: message.error || null
|
||||
});
|
||||
}
|
||||
} else if (message.type === 'getDiscoveredRelaysResult') {
|
||||
// Handle getDiscoveredRelays response
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
if (pending) {
|
||||
pendingRequests.delete(message.requestId);
|
||||
pending.resolve(message.relays || []);
|
||||
}
|
||||
} else if (message.type === 'fetchCachedProfileResult') {
|
||||
// Handle cached profile response
|
||||
const pending = pendingRequests.get(message.requestId);
|
||||
@@ -693,6 +749,52 @@ export function sendNip17Message(recipientPubkey, content) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcast relays (kind 10088) — thin wrappers over sendWorkerRequest.
|
||||
// The worker owns the kind 10088 state (active r-tags + skip-marked x-tags)
|
||||
// and unions the active set into every public publish automatically.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns { active: string[], skipped: { url, reason, timestamp }[] } */
|
||||
export function getBroadcastRelays() {
|
||||
return sendWorkerRequest(
|
||||
'getBroadcastRelays',
|
||||
{},
|
||||
15000,
|
||||
'getBroadcastRelays timeout'
|
||||
);
|
||||
}
|
||||
|
||||
/** Save a new active broadcast relay list (unioned with existing r-tags). */
|
||||
export function saveBroadcastRelays(urls) {
|
||||
return sendWorkerRequest(
|
||||
'saveBroadcastRelays',
|
||||
{ urls: Array.isArray(urls) ? urls : [] },
|
||||
30000,
|
||||
'saveBroadcastRelays timeout'
|
||||
);
|
||||
}
|
||||
|
||||
/** One-click seed: add all discovered relays to the broadcast list. Returns { added } */
|
||||
export function seedBroadcastRelays() {
|
||||
return sendWorkerRequest(
|
||||
'seedBroadcastRelays',
|
||||
{},
|
||||
30000,
|
||||
'seedBroadcastRelays timeout'
|
||||
);
|
||||
}
|
||||
|
||||
/** Remove a relay from the skip-mark set so it is included in future publishes. */
|
||||
export function unskipBroadcastRelay(url) {
|
||||
return sendWorkerRequest(
|
||||
'unskipBroadcastRelay',
|
||||
{ url: String(url || '') },
|
||||
30000,
|
||||
'unskipBroadcastRelay timeout'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from a single relay
|
||||
* Returns events from that specific relay
|
||||
@@ -762,6 +864,128 @@ export async function ndkFetchEvents(filters) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the NDK outbox tracker for a set of follow pubkeys.
|
||||
*
|
||||
* This proactively resolves each follow's kind 10002 relay list via
|
||||
* ndk.outboxTracker.trackUsers so that subsequent short-lived fetchEvents
|
||||
* subscriptions route to the correct write relays instead of racing the
|
||||
* tracker. Pre-warming is best-effort: the returned promise always resolves
|
||||
* (never rejects). On timeout it resolves with { ok: false, timedOut: true }.
|
||||
*
|
||||
* @param {string[]} pubkeys - Array of hex pubkeys to pre-warm.
|
||||
* @returns {Promise<{ok: boolean, count?: number, skipped?: boolean, timedOut?: boolean, error?: string}>}
|
||||
*/
|
||||
export async function warmOutbox(pubkeys) {
|
||||
if (!ndkWorker) {
|
||||
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
|
||||
}
|
||||
|
||||
const list = Array.isArray(pubkeys) ? pubkeys.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
return { ok: true, count: 0, skipped: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestCounter++;
|
||||
const requestId = `warmOutbox_${Date.now()}_${requestCounter}`;
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject: resolve });
|
||||
|
||||
// Best-effort: resolve (not reject) on timeout.
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve({ ok: false, timedOut: true });
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'warmOutbox',
|
||||
requestId: requestId,
|
||||
pubkeys: list
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle relay event logging in the worker.
|
||||
*
|
||||
* When enabled, the worker broadcasts relayEvent messages to all ports and
|
||||
* persists relay events to IndexedDB. When disabled (default), both are
|
||||
* skipped to avoid cross-page broadcast spam and IndexedDB write contention.
|
||||
* trackRelayRead/trackRelayWrite (relayActivity events) are not affected.
|
||||
*
|
||||
* Fire-and-forget: the returned promise resolves when the worker acknowledges,
|
||||
* but callers may ignore it.
|
||||
*
|
||||
* @param {boolean} enabled - Whether relay event logging should be on.
|
||||
* @returns {Promise<{ok: boolean, enabled: boolean, error?: string}>}
|
||||
*/
|
||||
export function setRelayEventLogging(enabled) {
|
||||
if (!ndkWorker) {
|
||||
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestCounter++;
|
||||
const requestId = `setRelayEventLogging_${Date.now()}_${requestCounter}`;
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject: resolve });
|
||||
|
||||
// Fire-and-forget timeout — resolve gracefully if the worker never
|
||||
// acknowledges (it always should, but be safe).
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve({ ok: false, enabled: !!enabled, error: 'setRelayEventLogging timeout' });
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'setRelayEventLogging',
|
||||
requestId: requestId,
|
||||
enabled: !!enabled
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of "discovered" relays currently in the NDK pool that are NOT
|
||||
* part of the user's own kind 10002 relay list. These are temporary outbox
|
||||
* relays NDK connected to in order to fetch events from followed authors who
|
||||
* write to relays the user does not subscribe to.
|
||||
*
|
||||
* Each entry is { url, status, connected, servingPubkeys: string[] }.
|
||||
*
|
||||
* @returns {Promise<Array<{url: string, status: number, connected: boolean, servingPubkeys: string[]}>>}
|
||||
*/
|
||||
export async function getDiscoveredRelays() {
|
||||
if (!ndkWorker) {
|
||||
throw new Error('NDK worker not initialized. Call initNDKPage() first.');
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
requestCounter++;
|
||||
const requestId = `getDiscoveredRelays_${Date.now()}_${requestCounter}`;
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject: resolve });
|
||||
|
||||
// Resolve with [] on timeout — this is a read-only diagnostic call.
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
resolve([]);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getDiscoveredRelays',
|
||||
requestId: requestId
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events from all relays in parallel
|
||||
* Returns events grouped by relay URL
|
||||
@@ -884,7 +1108,7 @@ export async function getRelayData() {
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set timeout
|
||||
// Set timeout (15s — worker may be busy processing outbox/NDK events)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
@@ -892,7 +1116,7 @@ export async function getRelayData() {
|
||||
console.warn('[init-ndk] Get relay data timeout, returning empty array');
|
||||
resolve([]);
|
||||
}
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getRelayData',
|
||||
@@ -917,7 +1141,7 @@ export async function getRelayStats() {
|
||||
|
||||
pendingRequests.set(requestId, { resolve, reject });
|
||||
|
||||
// Set timeout
|
||||
// Set timeout (15s — worker may be busy processing outbox/NDK events)
|
||||
setTimeout(() => {
|
||||
if (pendingRequests.has(requestId)) {
|
||||
pendingRequests.delete(requestId);
|
||||
@@ -925,7 +1149,7 @@ export async function getRelayStats() {
|
||||
console.warn('[init-ndk] Get relay stats timeout, returning empty object');
|
||||
resolve({});
|
||||
}
|
||||
}, 5000);
|
||||
}, 15000);
|
||||
|
||||
ndkWorker.port.postMessage({
|
||||
type: 'getRelayStats',
|
||||
@@ -1014,7 +1238,8 @@ export async function injectHeaderLoginButton() {
|
||||
extension: true,
|
||||
local: true,
|
||||
nip46: true,
|
||||
seedphrase: true
|
||||
seedphrase: true,
|
||||
nsigner: true
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1423,6 +1648,10 @@ export function walletShutdown() {
|
||||
return sendWorkerRequest('walletShutdown', {}, 10000, 'walletShutdown timeout');
|
||||
}
|
||||
|
||||
export function walletRepublish() {
|
||||
return sendWorkerRequest('walletRepublish', {}, 30000, 'walletRepublish timeout');
|
||||
}
|
||||
|
||||
export function walletPublishMintList(relays = [], receiveMints = []) {
|
||||
return sendWorkerRequest('walletPublishMintList', { relays, receiveMints }, 25000, 'walletPublishMintList timeout');
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import { createProfileCache } from './profile-cache.mjs';
|
||||
import { mountDotMenu } from './dot-menu.mjs';
|
||||
import {
|
||||
promptZapDetails,
|
||||
promptNutzapDetails,
|
||||
prepareZapInvoiceForEvent,
|
||||
resolveNutzapSpecForPubkey,
|
||||
resolveZapCapabilities,
|
||||
extractZapAmount as extractZapAmountFromReceipt,
|
||||
extractZapComment as extractZapCommentFromReceipt
|
||||
} from './zaps.mjs';
|
||||
@@ -879,12 +881,13 @@ const ICON_LABELS = {
|
||||
like: { label: 'Like', activeLabel: 'Like' },
|
||||
comment: { label: 'Comment', activeLabel: 'Comment' },
|
||||
quote: { label: 'Quote', activeLabel: 'Quote' },
|
||||
zap: { label: 'Zap', activeLabel: 'Zap' }
|
||||
zap: { label: 'Zap', activeLabel: 'Zap' },
|
||||
nutzap: { label: 'Nutzap', activeLabel: 'Nutzap' }
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate HTML for interaction label text
|
||||
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap'
|
||||
* @param {string} type - Icon type: 'like', 'comment', 'quote', 'zap', 'nutzap'
|
||||
* @param {boolean} active - Whether the label is in active/selected state
|
||||
* @returns {string} HTML string with label text
|
||||
*/
|
||||
@@ -912,16 +915,78 @@ export function formatTimeAgo(timestamp) {
|
||||
}
|
||||
|
||||
// Store timestamps for real-time updates
|
||||
const timeAgoElements = new Map(); // element -> timestamp
|
||||
const timeAgoElements = new Map(); // element -> { timestamp, eventId }
|
||||
|
||||
// Track relay publish info per event ID from broadcastProgress events.
|
||||
// Keyed by event ID → { count, urls } where urls is an array of relay URLs.
|
||||
const relayInfoByEventId = new Map();
|
||||
|
||||
// Listen for broadcast progress events to capture final relay counts and URLs.
|
||||
// The worker emits ndkBroadcastProgress with phase 'done' containing the
|
||||
// total successful count and the list of relay URLs. We store it so post
|
||||
// cards can show "21r - 1h" with a hover tooltip listing the relays.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('ndkBroadcastProgress', (event) => {
|
||||
const d = event.detail;
|
||||
if (!d || d.phase !== 'done') return;
|
||||
const eventId = d.eventId;
|
||||
if (!eventId) return;
|
||||
relayInfoByEventId.set(eventId, {
|
||||
count: d.successful || 0,
|
||||
urls: Array.isArray(d.relayUrls) ? d.relayUrls : [],
|
||||
});
|
||||
// Update any already-rendered time elements for this event.
|
||||
timeAgoElements.forEach((info, element) => {
|
||||
if (info.eventId === eventId && element.isConnected) {
|
||||
updateTimeAgoElement(element, info);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the hover tooltip text for a relay list.
|
||||
* Shows "Published to N relays:" followed by the URL list, truncated.
|
||||
* @param {number} count - Number of successful relays
|
||||
* @param {string[]} urls - Array of relay URLs
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildRelayTooltip(count, urls) {
|
||||
if (!count || count === 0) return '';
|
||||
const header = `Published to ${count} relay${count === 1 ? '' : 's'}:`;
|
||||
if (!urls || urls.length === 0) return header;
|
||||
// Show up to 50 relay URLs in the tooltip; beyond that, show a summary.
|
||||
if (urls.length <= 50) {
|
||||
return header + '\n' + urls.join('\n');
|
||||
}
|
||||
return header + '\n' + urls.slice(0, 50).join('\n') + `\n… and ${urls.length - 50} more`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a time-ago element's text and tooltip from its stored info.
|
||||
* @param {HTMLElement} element - The element to update
|
||||
* @param {{timestamp: number, eventId: string}} info - Stored info
|
||||
*/
|
||||
function updateTimeAgoElement(element, info) {
|
||||
const timeStr = formatTimeAgo(info.timestamp);
|
||||
if (info.eventId && relayInfoByEventId.has(info.eventId)) {
|
||||
const relayInfo = relayInfoByEventId.get(info.eventId);
|
||||
element.textContent = `${relayInfo.count}r - ${timeStr}`;
|
||||
element.title = buildRelayTooltip(relayInfo.count, relayInfo.urls);
|
||||
} else {
|
||||
element.textContent = timeStr;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an element for time updates
|
||||
* @param {HTMLElement} element - The element to update
|
||||
* @param {number} timestamp - Unix timestamp in seconds
|
||||
* @param {string} [eventId] - Optional event ID for relay count display
|
||||
*/
|
||||
export function registerTimeAgo(element, timestamp) {
|
||||
timeAgoElements.set(element, timestamp);
|
||||
element.textContent = formatTimeAgo(timestamp);
|
||||
export function registerTimeAgo(element, timestamp, eventId) {
|
||||
timeAgoElements.set(element, { timestamp, eventId });
|
||||
updateTimeAgoElement(element, { timestamp, eventId });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -929,9 +994,9 @@ export function registerTimeAgo(element, timestamp) {
|
||||
* Call this periodically (e.g., every 30 seconds)
|
||||
*/
|
||||
export function updateTimeAgos() {
|
||||
timeAgoElements.forEach((timestamp, element) => {
|
||||
timeAgoElements.forEach((info, element) => {
|
||||
if (element.isConnected) {
|
||||
element.textContent = formatTimeAgo(timestamp);
|
||||
updateTimeAgoElement(element, info);
|
||||
} else {
|
||||
// Clean up detached elements
|
||||
timeAgoElements.delete(element);
|
||||
@@ -957,6 +1022,8 @@ let publishEventFn = null;
|
||||
let walletPayInvoiceFn = null;
|
||||
let walletSendNutzapFn = null;
|
||||
let walletFetchMintListFn = null;
|
||||
let walletGetMintsFn = null;
|
||||
let walletGetBalanceFn = null;
|
||||
let getRelayDataFn = null;
|
||||
let getUserSettingsFn = null;
|
||||
let patchUserSettingsFn = null;
|
||||
@@ -1049,7 +1116,7 @@ export function renderFooterRow(eventId, eventData, options = {}) {
|
||||
onClick: () => handleQuoteClick(eventId, eventData.pubkey, currentPubkey, quoteItem)
|
||||
});
|
||||
|
||||
// Zap button
|
||||
// Zap button (Lightning only)
|
||||
const zapItem = createInteractionItem({
|
||||
type: 'zap',
|
||||
count: state.zapTotal,
|
||||
@@ -1059,8 +1126,16 @@ export function renderFooterRow(eventId, eventData, options = {}) {
|
||||
});
|
||||
updateZapCountDisplay(zapItem, state);
|
||||
|
||||
const nutzapCountEl = createNutzapCountDisplay(state);
|
||||
|
||||
// Nutzap button (Cashu ecash)
|
||||
const nutzapItem = createInteractionItem({
|
||||
type: 'nutzap',
|
||||
count: state.nutzapTotal,
|
||||
active: false,
|
||||
title: 'Nutzap',
|
||||
onClick: () => handleNutzapClick(eventId, eventData.pubkey, currentPubkey, nutzapItem)
|
||||
});
|
||||
updateNutzapButtonCount(nutzapItem, state);
|
||||
|
||||
const midControls = document.createElement('div');
|
||||
midControls.className = 'divPostMidControls';
|
||||
|
||||
@@ -1069,17 +1144,23 @@ export function renderFooterRow(eventId, eventData, options = {}) {
|
||||
midControls.appendChild(quoteItem);
|
||||
|
||||
container.appendChild(zapItem);
|
||||
container.appendChild(nutzapCountEl);
|
||||
container.appendChild(nutzapItem);
|
||||
container.appendChild(midControls);
|
||||
|
||||
// Time as the final item in the same row
|
||||
// Time as the final item in the same row.
|
||||
// Pass eventId so registerTimeAgo can prepend the relay count
|
||||
// (e.g., "21r - 1h") when a broadcastProgress 'done' event arrives.
|
||||
const timeEl = document.createElement('span');
|
||||
timeEl.className = 'divPostTime';
|
||||
registerTimeAgo(timeEl, eventData.created_at);
|
||||
registerTimeAgo(timeEl, eventData.created_at, eventId);
|
||||
container.appendChild(timeEl);
|
||||
|
||||
footerRow.appendChild(container);
|
||||
|
||||
|
||||
// Async, non-blocking capability dimming.
|
||||
// Resolved after the bar is in the DOM so feed rendering is never blocked.
|
||||
setTimeout(() => applyZapCapabilityDimming(eventId, eventData.pubkey, zapItem, nutzapItem), 0);
|
||||
|
||||
return footerRow;
|
||||
}
|
||||
|
||||
@@ -1126,7 +1207,7 @@ export function renderInteractionBar(postId, postData, options = {}) {
|
||||
onClick: () => handleQuoteClick(postId, postData.pubkey, currentPubkey, quoteItem)
|
||||
});
|
||||
|
||||
// Zap button
|
||||
// Zap button (Lightning only)
|
||||
const zapItem = createInteractionItem({
|
||||
type: 'zap',
|
||||
count: state.zapTotal,
|
||||
@@ -1137,14 +1218,27 @@ export function renderInteractionBar(postId, postData, options = {}) {
|
||||
});
|
||||
updateZapCountDisplay(zapItem, state);
|
||||
|
||||
const nutzapCountEl = createNutzapCountDisplay(state);
|
||||
// Nutzap button (Cashu ecash)
|
||||
const nutzapItem = createInteractionItem({
|
||||
type: 'nutzap',
|
||||
count: state.nutzapTotal,
|
||||
active: false,
|
||||
showCount: state.nutzapTotal > 0,
|
||||
title: 'Nutzap',
|
||||
onClick: () => handleNutzapClick(postId, postData.pubkey, currentPubkey, nutzapItem)
|
||||
});
|
||||
updateNutzapButtonCount(nutzapItem, state);
|
||||
|
||||
container.appendChild(zapItem);
|
||||
container.appendChild(nutzapCountEl);
|
||||
container.appendChild(nutzapItem);
|
||||
container.appendChild(likeItem);
|
||||
container.appendChild(commentItem);
|
||||
container.appendChild(quoteItem);
|
||||
|
||||
// Async, non-blocking capability dimming.
|
||||
// Resolved after the bar is in the DOM so feed rendering is never blocked.
|
||||
setTimeout(() => applyZapCapabilityDimming(postId, postData.pubkey, zapItem, nutzapItem), 0);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@@ -1194,10 +1288,10 @@ export function updateInteractionBar(postId, state) {
|
||||
updateZapCountDisplay(zapItem, state);
|
||||
}
|
||||
|
||||
// Update nutzap count display
|
||||
const nutzapCountEl = bar.querySelector('.nutzap-count-display');
|
||||
if (nutzapCountEl) {
|
||||
updateNutzapCountDisplay(nutzapCountEl, state);
|
||||
// Update nutzap button count
|
||||
const nutzapItem = bar.querySelector('.interaction-item.nutzap');
|
||||
if (nutzapItem) {
|
||||
updateNutzapButtonCount(nutzapItem, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1272,34 +1366,80 @@ function updateZapCountDisplay(itemEl, state = {}) {
|
||||
countEl.textContent = formatCount(zapTotal);
|
||||
}
|
||||
|
||||
function createNutzapCountDisplay(state = {}) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'nutzap-count-display';
|
||||
el.title = 'Nutzaps';
|
||||
|
||||
const emojiEl = document.createElement('span');
|
||||
emojiEl.className = 'nutzap-emoji';
|
||||
emojiEl.textContent = '🥜';
|
||||
|
||||
const countEl = document.createElement('span');
|
||||
countEl.className = 'nutzap-count-value';
|
||||
|
||||
el.appendChild(emojiEl);
|
||||
el.appendChild(countEl);
|
||||
|
||||
updateNutzapCountDisplay(el, state);
|
||||
return el;
|
||||
}
|
||||
|
||||
function updateNutzapCountDisplay(el, state = {}) {
|
||||
const countEl = el?.querySelector?.('.nutzap-count-value');
|
||||
/**
|
||||
* Update the count display on the Nutzap interaction button.
|
||||
* Mirrors updateZapCountDisplay but for the nutzap button.
|
||||
* @param {HTMLElement} itemEl - The nutzap interaction item element
|
||||
* @param {Object} state - The interaction state
|
||||
*/
|
||||
function updateNutzapButtonCount(itemEl, state = {}) {
|
||||
const countEl = itemEl?.querySelector?.('.interaction-count');
|
||||
if (!countEl) return;
|
||||
|
||||
const nutzapTotal = Math.max(0, Number(state?.nutzapTotal || 0));
|
||||
const hasCount = nutzapTotal > 0;
|
||||
|
||||
countEl.textContent = formatCount(nutzapTotal);
|
||||
el.classList.toggle('has-count', hasCount);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ZAP CAPABILITY DIMMING
|
||||
// =============================================================================
|
||||
//
|
||||
// Instead of separate capability badges, the Zap and Nutzap buttons themselves
|
||||
// indicate capability via dimming:
|
||||
// - Zap button dimmed when recipient has no lud16 Lightning address
|
||||
// - Nutzap button dimmed when recipient has no shared mints for nutzaps
|
||||
//
|
||||
// Dimming is applied asynchronously after the bar is in the DOM so feed
|
||||
// rendering is never blocked. Colors come exclusively from CSS variables.
|
||||
|
||||
/**
|
||||
* Dim the Zap and/or Nutzap buttons based on the recipient's zap capabilities.
|
||||
* Resolves capabilities async and applies a `.dimmed` class + tooltip to
|
||||
* buttons whose rail is unavailable. Non-throwing.
|
||||
*
|
||||
* @param {string} postId - the event id (used for logging only)
|
||||
* @param {string} pubkey - the post author's pubkey
|
||||
* @param {HTMLElement} zapItem - the Zap (Lightning) interaction item
|
||||
* @param {HTMLElement} nutzapItem - the Nutzap interaction item
|
||||
*/
|
||||
function applyZapCapabilityDimming(postId, pubkey, zapItem, nutzapItem) {
|
||||
if (!pubkey) return;
|
||||
if (typeof walletFetchMintListFn !== 'function' && typeof fetchProfile !== 'function') {
|
||||
return; // nothing to resolve with
|
||||
}
|
||||
if (!zapItem && !nutzapItem) return;
|
||||
|
||||
resolveZapCapabilities(pubkey, {
|
||||
fetchProfile,
|
||||
fetchMintList: walletFetchMintListFn || undefined,
|
||||
getSenderMints: walletGetMintsFn || undefined,
|
||||
logPrefix: '[post-interactions][zap-caps]'
|
||||
}).then((caps) => {
|
||||
// Elements may have been detached from the DOM by the time this resolves.
|
||||
if (zapItem && zapItem.isConnected) {
|
||||
if (!caps.canLightning) {
|
||||
zapItem.classList.add('dimmed');
|
||||
zapItem.title = 'No Lightning address';
|
||||
} else {
|
||||
zapItem.classList.remove('dimmed');
|
||||
zapItem.title = caps.lud16 ? `Lightning: ${caps.lud16}` : 'Zap';
|
||||
}
|
||||
}
|
||||
|
||||
if (nutzapItem && nutzapItem.isConnected) {
|
||||
if (!caps.canNutzap) {
|
||||
nutzapItem.classList.add('dimmed');
|
||||
nutzapItem.title = caps.hasMintList
|
||||
? 'No shared mints for nutzap'
|
||||
: 'No shared mints for nutzap';
|
||||
} else {
|
||||
nutzapItem.classList.remove('dimmed');
|
||||
nutzapItem.title = 'Nutzap';
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.warn('[post-interactions][zap-caps] dimming failed', error?.message || error);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1323,6 +1463,66 @@ function isZapTimeoutError(error) {
|
||||
return msg.includes('timeout');
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a zap/melt payment error into a user-friendly message.
|
||||
* Hides raw mint API errors while still being informative.
|
||||
* @param {Error|*} error
|
||||
* @returns {string}
|
||||
*/
|
||||
function friendlyZapErrorMessage(error) {
|
||||
const raw = String(error?.message || error || '').trim();
|
||||
const msg = raw.toLowerCase();
|
||||
|
||||
if (msg.includes('melt') || msg.includes('swap') || msg.includes('proof')) {
|
||||
return 'Mint couldn\'t route Lightning payment. The mint may not have enough Lightning liquidity.';
|
||||
}
|
||||
if (msg.includes('timeout')) {
|
||||
return 'Payment timed out. The mint may be slow or unresponsive.';
|
||||
}
|
||||
if (msg.includes('insufficient') || msg.includes('balance')) {
|
||||
return 'Insufficient Cashu balance for this payment.';
|
||||
}
|
||||
return raw || 'Zap failed.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current user's Cashu balance in sats.
|
||||
* Returns null when the wallet function is unavailable or the fetch fails.
|
||||
* @returns {Promise<number|null>}
|
||||
*/
|
||||
async function fetchWalletBalanceSats() {
|
||||
if (typeof walletGetBalanceFn !== 'function') return null;
|
||||
try {
|
||||
const result = await walletGetBalanceFn();
|
||||
const balance = Number(result?.balance ?? result?.sats ?? result);
|
||||
if (Number.isFinite(balance) && balance >= 0) {
|
||||
return Math.floor(balance);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[post-interactions][zap] fetchWalletBalanceSats:failed', error?.message || error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the wallet balance display after a zap.
|
||||
* Re-fetches the balance and dispatches an `ndkWalletBalance` event so any
|
||||
* footer/dialog balance displays can update. Non-throwing.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function refreshWalletBalanceDisplay() {
|
||||
const balanceSats = await fetchWalletBalanceSats();
|
||||
if (balanceSats === null) return;
|
||||
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('ndkWalletBalance', {
|
||||
detail: { balanceSats, source: 'post-interactions-zap' }
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn('[post-interactions][zap] refreshWalletBalanceDisplay:dispatch-failed', error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERACTION HANDLERS
|
||||
// =============================================================================
|
||||
@@ -1558,6 +1758,18 @@ async function saveZapDefaultsToSettings({ amountSats, comment }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Zap (Lightning) button click.
|
||||
*
|
||||
* Sends a Lightning zap via Cashu melt: create a LN invoice from the
|
||||
* recipient's lud16, then pay it via walletPayInvoiceFn. No rail selector —
|
||||
* nutzaps have their own button and handler.
|
||||
*
|
||||
* @param {string} postId - Post ID
|
||||
* @param {string} postPubkey - Post author's pubkey
|
||||
* @param {string} currentPubkey - Current user's pubkey
|
||||
* @param {HTMLElement} itemEl - The interaction item element
|
||||
*/
|
||||
async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
if (!postId || !postPubkey || !itemEl) return;
|
||||
if (itemEl.dataset.busy === '1') return;
|
||||
@@ -1583,34 +1795,31 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
let paymentSucceeded = false;
|
||||
|
||||
try {
|
||||
if (typeof walletPayInvoiceFn !== 'function' && typeof walletSendNutzapFn !== 'function') {
|
||||
throw new Error('Cashu wallet payer is not configured on this page');
|
||||
if (typeof walletPayInvoiceFn !== 'function') {
|
||||
throw new Error('Lightning zap unavailable (walletPayInvoice not configured)');
|
||||
}
|
||||
|
||||
let nutzapSpec = null;
|
||||
if (typeof walletFetchMintListFn === 'function') {
|
||||
// --- Fetch the user's Cashu balance for the dialog ----------------------
|
||||
let balanceSats = null;
|
||||
if (typeof walletGetBalanceFn === 'function') {
|
||||
try {
|
||||
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
|
||||
fetchMintList: walletFetchMintListFn,
|
||||
logPrefix: '[post-interactions][nutzap]'
|
||||
});
|
||||
} catch (_nutzapDiscoveryError) {
|
||||
nutzapSpec = null;
|
||||
const balanceResult = await walletGetBalanceFn();
|
||||
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
|
||||
if (Number.isFinite(balance) && balance >= 0) {
|
||||
balanceSats = Math.floor(balance);
|
||||
}
|
||||
} catch (balanceError) {
|
||||
console.warn('[post-interactions][zap] handleZapClick:balance-failed', balanceError?.message || balanceError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[post-interactions][zap] handleZapClick:nutzap-support', {
|
||||
recipient: String(postPubkey || '').slice(0, 8) + '…',
|
||||
hasNutzapSupport: Boolean(nutzapSpec?.mints?.length),
|
||||
mintCount: Array.isArray(nutzapSpec?.mints) ? nutzapSpec.mints.length : 0
|
||||
});
|
||||
|
||||
const zapDefaults = await getZapDefaultsFromSettings();
|
||||
const details = await promptZapDetails({
|
||||
defaultAmountSats: zapDefaults.amountSats,
|
||||
defaultComment: zapDefaults.comment,
|
||||
defaultShouldZap: true,
|
||||
onSaveDefault: saveZapDefaultsToSettings
|
||||
onSaveDefault: saveZapDefaultsToSettings,
|
||||
balanceSats
|
||||
});
|
||||
if (!details) {
|
||||
console.log('[post-interactions][zap] handleZapClick:cancelled-by-user');
|
||||
@@ -1652,40 +1861,59 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nutzapSpec?.mints?.length > 0 && typeof walletSendNutzapFn === 'function') {
|
||||
if (countEl) countEl.textContent = 'send…';
|
||||
console.log('[post-interactions][zap] handleZapClick:sending-nutzap');
|
||||
const nutzapResult = await walletSendNutzapFn({
|
||||
amount: details.amountSats,
|
||||
memo: details.comment,
|
||||
targetPubkey: postPubkey,
|
||||
eventId: postId,
|
||||
recipientMints: nutzapSpec.mints,
|
||||
recipientP2pk: nutzapSpec.p2pk,
|
||||
recipientRelays: nutzapSpec.relays
|
||||
});
|
||||
console.log('[post-interactions][zap] handleZapClick:nutzap-ok', nutzapResult || {});
|
||||
} else {
|
||||
if (typeof walletPayInvoiceFn !== 'function') {
|
||||
throw new Error('LN zap fallback unavailable (walletPayInvoice missing)');
|
||||
// --- Pre-flight balance check (Phase 5) --------------------------------
|
||||
// A final safety net right before sending, in case the user ignored the
|
||||
// dialog warning or the balance changed between dialog and send.
|
||||
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
|
||||
const preflightBalance = await fetchWalletBalanceSats();
|
||||
|
||||
if (preflightBalance !== null) {
|
||||
if (preflightBalance < zapAmount) {
|
||||
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
|
||||
console.warn('[post-interactions][zap] handleZapClick:insufficient-balance', {
|
||||
balance: preflightBalance, needed: zapAmount
|
||||
});
|
||||
if (countEl) {
|
||||
countEl.textContent = 'low';
|
||||
countEl.title = msg;
|
||||
setTimeout(() => {
|
||||
updateZapCountDisplay(itemEl, getPostState(postId));
|
||||
}, 2200);
|
||||
}
|
||||
window.alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const { invoice } = await prepareZapInvoiceForEvent({
|
||||
eventId: postId,
|
||||
recipientPubkey: postPubkey,
|
||||
amountSats: details.amountSats,
|
||||
comment: details.comment,
|
||||
fetchProfile,
|
||||
getRelayData: getRelayDataFn,
|
||||
logPrefix: '[post-interactions][zap]'
|
||||
});
|
||||
|
||||
if (countEl) countEl.textContent = 'pay…';
|
||||
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
|
||||
const paymentResult = await walletPayInvoiceFn(invoice);
|
||||
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
|
||||
// "Barely sufficient" warning: remaining balance would be less than
|
||||
// 10% of the current balance.
|
||||
const remaining = preflightBalance - zapAmount;
|
||||
const tenPercent = Math.floor(preflightBalance * 0.10);
|
||||
if (remaining > 0 && remaining < tenPercent) {
|
||||
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
|
||||
const proceed = window.confirm(confirmMsg);
|
||||
if (!proceed) {
|
||||
console.log('[post-interactions][zap] handleZapClick:declined-low-balance');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Create the LN invoice and pay it via Cashu melt -------------------
|
||||
const { invoice } = await prepareZapInvoiceForEvent({
|
||||
eventId: postId,
|
||||
recipientPubkey: postPubkey,
|
||||
amountSats: details.amountSats,
|
||||
comment: details.comment,
|
||||
fetchProfile,
|
||||
getRelayData: getRelayDataFn,
|
||||
logPrefix: '[post-interactions][zap]'
|
||||
});
|
||||
|
||||
if (countEl) countEl.textContent = 'pay…';
|
||||
console.log('[post-interactions][zap] handleZapClick:paying-invoice');
|
||||
const paymentResult = await walletPayInvoiceFn(invoice);
|
||||
console.log('[post-interactions][zap] handleZapClick:payment-ok', paymentResult || {});
|
||||
|
||||
paymentSucceeded = true;
|
||||
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
|
||||
itemEl.classList.add('zap-success');
|
||||
@@ -1702,15 +1930,28 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
itemEl.classList.remove('zap-success');
|
||||
updateZapCountDisplay(itemEl, state);
|
||||
}, 1800);
|
||||
|
||||
// --- Post-zap balance refresh (Phase 5) --------------------------------
|
||||
// Refresh the wallet balance display so the user sees their updated
|
||||
// balance after a successful zap. Non-blocking — failures are logged
|
||||
// but never surface to the user since the zap itself succeeded.
|
||||
refreshWalletBalanceDisplay().catch((refreshError) => {
|
||||
console.warn('[post-interactions][zap] handleZapClick:balance-refresh-failed', refreshError?.message || refreshError);
|
||||
});
|
||||
} catch (error) {
|
||||
const timeoutFailure = isZapTimeoutError(error);
|
||||
const friendlyMessage = friendlyZapErrorMessage(error);
|
||||
console.error('[post-interactions][zap] handleZapClick:failed', error);
|
||||
if (countEl) {
|
||||
countEl.textContent = timeoutFailure ? 'check' : 'fail';
|
||||
countEl.title = friendlyMessage;
|
||||
setTimeout(() => {
|
||||
updateZapCountDisplay(itemEl, getPostState(postId));
|
||||
}, timeoutFailure ? 2800 : 2200);
|
||||
}
|
||||
// Surface a user-friendly error message. Avoids exposing raw mint API
|
||||
// errors while still being informative.
|
||||
window.alert(friendlyMessage);
|
||||
itemEl.classList.remove('zap-success');
|
||||
} finally {
|
||||
clearInterval(zapPulseInterval);
|
||||
@@ -1728,6 +1969,255 @@ async function handleZapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Nutzap (Cashu ecash) button click.
|
||||
*
|
||||
* Checks the recipient's kind 10019 for shared mints, shows a nutzap-specific
|
||||
* dialog (with the recipient's accepted mints), and sends ecash via
|
||||
* walletSendNutzapFn. Mirrors handleZapClick's UI feedback (pulse animation,
|
||||
* count update, balance check).
|
||||
*
|
||||
* @param {string} postId - Post ID
|
||||
* @param {string} postPubkey - Post author's pubkey
|
||||
* @param {string} currentPubkey - Current user's pubkey
|
||||
* @param {HTMLElement} itemEl - The nutzap interaction item element
|
||||
*/
|
||||
async function handleNutzapClick(postId, postPubkey, currentPubkey, itemEl) {
|
||||
if (!postId || !postPubkey || !itemEl) return;
|
||||
if (itemEl.dataset.busy === '1') return;
|
||||
|
||||
const iconContainer = itemEl.querySelector('.interaction-icon-container');
|
||||
const countEl = itemEl.querySelector('.interaction-count');
|
||||
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:start', {
|
||||
postId: String(postId || '').slice(0, 8) + '…',
|
||||
recipient: String(postPubkey || '').slice(0, 8) + '…'
|
||||
});
|
||||
|
||||
itemEl.dataset.busy = '1';
|
||||
itemEl.classList.add('zap-pending');
|
||||
updateIconState(iconContainer, 'nutzap', true);
|
||||
|
||||
let pendingAccent = false;
|
||||
const zapPulseInterval = setInterval(() => {
|
||||
pendingAccent = !pendingAccent;
|
||||
itemEl.classList.toggle('zap-pending-accent', pendingAccent);
|
||||
}, 500);
|
||||
|
||||
let paymentSucceeded = false;
|
||||
|
||||
try {
|
||||
if (typeof walletSendNutzapFn !== 'function') {
|
||||
throw new Error('Nutzap unavailable (walletSendNutzap not configured)');
|
||||
}
|
||||
|
||||
// --- Resolve zap capabilities to confirm nutzap is possible -------------
|
||||
let zapCaps = null;
|
||||
try {
|
||||
zapCaps = await resolveZapCapabilities(postPubkey, {
|
||||
fetchProfile,
|
||||
fetchMintList: walletFetchMintListFn || undefined,
|
||||
getSenderMints: walletGetMintsFn || undefined,
|
||||
logPrefix: '[post-interactions][nutzap-caps]'
|
||||
});
|
||||
} catch (capsError) {
|
||||
console.warn('[post-interactions][nutzap] handleNutzapClick:caps-failed', capsError?.message || capsError);
|
||||
}
|
||||
|
||||
const canNutzap = Boolean(zapCaps?.canNutzap);
|
||||
const sharedMints = Array.isArray(zapCaps?.sharedMints) ? zapCaps.sharedMints : [];
|
||||
const recipientMints = Array.isArray(zapCaps?.nutzapMints) ? zapCaps.nutzapMints : [];
|
||||
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:caps', {
|
||||
recipient: String(postPubkey || '').slice(0, 8) + '…',
|
||||
canNutzap,
|
||||
sharedMints: sharedMints.length,
|
||||
recipientMints: recipientMints.length
|
||||
});
|
||||
|
||||
if (!canNutzap || sharedMints.length === 0) {
|
||||
window.alert('Recipient cannot receive nutzaps (no shared mints)');
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:no-shared-mints');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Resolve the nutzap spec (mints, p2pk, relays) for the send --------
|
||||
let nutzapSpec = null;
|
||||
try {
|
||||
nutzapSpec = await resolveNutzapSpecForPubkey(postPubkey, {
|
||||
fetchMintList: walletFetchMintListFn,
|
||||
logPrefix: '[post-interactions][nutzap]'
|
||||
});
|
||||
} catch (nutzapDiscoveryError) {
|
||||
console.warn('[post-interactions][nutzap] handleNutzapClick:spec-failed', nutzapDiscoveryError?.message || nutzapDiscoveryError);
|
||||
window.alert('Recipient cannot receive nutzaps (no shared mints)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nutzapSpec?.mints?.length) {
|
||||
window.alert('Recipient cannot receive nutzaps (no shared mints)');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Fetch the user's Cashu balance for the dialog ----------------------
|
||||
let balanceSats = null;
|
||||
if (typeof walletGetBalanceFn === 'function') {
|
||||
try {
|
||||
const balanceResult = await walletGetBalanceFn();
|
||||
const balance = Number(balanceResult?.balance ?? balanceResult?.sats ?? balanceResult);
|
||||
if (Number.isFinite(balance) && balance >= 0) {
|
||||
balanceSats = Math.floor(balance);
|
||||
}
|
||||
} catch (balanceError) {
|
||||
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-failed', balanceError?.message || balanceError);
|
||||
}
|
||||
}
|
||||
|
||||
const zapDefaults = await getZapDefaultsFromSettings();
|
||||
const details = await promptNutzapDetails({
|
||||
defaultAmountSats: zapDefaults.amountSats,
|
||||
defaultComment: zapDefaults.comment,
|
||||
defaultShouldZap: true,
|
||||
onSaveDefault: saveZapDefaultsToSettings,
|
||||
balanceSats,
|
||||
recipientMints: nutzapSpec.mints,
|
||||
sharedMints
|
||||
});
|
||||
if (!details) {
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:cancelled-by-user');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:details', {
|
||||
amountSats: details.amountSats,
|
||||
hasComment: Boolean(String(details.comment || '').trim()),
|
||||
shouldZap: Boolean(details.shouldZap)
|
||||
});
|
||||
|
||||
const state = getPostState(postId);
|
||||
if (!state.userLiked && currentPubkey) {
|
||||
const interactionBar = itemEl.closest('.divPostInteractions');
|
||||
const likeItem = interactionBar?.querySelector('.interaction-item.like');
|
||||
if (likeItem) {
|
||||
try {
|
||||
await handleLikeClick(postId, postPubkey, currentPubkey, likeItem);
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:auto-like:ok');
|
||||
} catch (likeError) {
|
||||
console.error('[post-interactions][nutzap] handleNutzapClick:auto-like:failed', likeError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shouldZap = details.shouldZap !== false;
|
||||
if (!shouldZap) {
|
||||
paymentSucceeded = true;
|
||||
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
|
||||
if (countEl) {
|
||||
countEl.textContent = '';
|
||||
setTimeout(() => {
|
||||
updateNutzapButtonCount(itemEl, getPostState(postId));
|
||||
}, 150);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Pre-flight balance check (Phase 5) --------------------------------
|
||||
const zapAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
|
||||
const preflightBalance = await fetchWalletBalanceSats();
|
||||
|
||||
if (preflightBalance !== null) {
|
||||
if (preflightBalance < zapAmount) {
|
||||
const msg = `Insufficient balance. Current: ${preflightBalance} sats, needed: ${zapAmount} sats`;
|
||||
console.warn('[post-interactions][nutzap] handleNutzapClick:insufficient-balance', {
|
||||
balance: preflightBalance, needed: zapAmount
|
||||
});
|
||||
if (countEl) {
|
||||
countEl.textContent = 'low';
|
||||
countEl.title = msg;
|
||||
setTimeout(() => {
|
||||
updateNutzapButtonCount(itemEl, getPostState(postId));
|
||||
}, 2200);
|
||||
}
|
||||
window.alert(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = preflightBalance - zapAmount;
|
||||
const tenPercent = Math.floor(preflightBalance * 0.10);
|
||||
if (remaining > 0 && remaining < tenPercent) {
|
||||
const confirmMsg = `This will use most of your balance (${remaining} sats remaining). Continue?`;
|
||||
const proceed = window.confirm(confirmMsg);
|
||||
if (!proceed) {
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:declined-low-balance');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Send the nutzap (Cashu ecash) -------------------------------------
|
||||
if (countEl) countEl.textContent = 'send…';
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:sending-nutzap');
|
||||
const nutzapResult = await walletSendNutzapFn({
|
||||
amount: details.amountSats,
|
||||
memo: details.comment,
|
||||
targetPubkey: postPubkey,
|
||||
eventId: postId,
|
||||
recipientMints: nutzapSpec.mints,
|
||||
recipientP2pk: nutzapSpec.p2pk,
|
||||
recipientRelays: nutzapSpec.relays
|
||||
});
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:nutzap-ok', nutzapResult || {});
|
||||
|
||||
paymentSucceeded = true;
|
||||
itemEl.classList.remove('zap-pending', 'zap-pending-accent');
|
||||
itemEl.classList.add('zap-success');
|
||||
|
||||
if (countEl) countEl.textContent = 'sent';
|
||||
|
||||
const sentAmount = Math.max(0, Math.floor(Number(details.amountSats || 0)));
|
||||
if (sentAmount > 0) {
|
||||
state.nutzapTotal = Math.max(0, Number(state.nutzapTotal || 0)) + sentAmount;
|
||||
updateNutzapButtonCount(itemEl, state);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
itemEl.classList.remove('zap-success');
|
||||
updateNutzapButtonCount(itemEl, state);
|
||||
}, 1800);
|
||||
|
||||
// --- Post-nutzap balance refresh ---------------------------------------
|
||||
refreshWalletBalanceDisplay().catch((refreshError) => {
|
||||
console.warn('[post-interactions][nutzap] handleNutzapClick:balance-refresh-failed', refreshError?.message || refreshError);
|
||||
});
|
||||
} catch (error) {
|
||||
const timeoutFailure = isZapTimeoutError(error);
|
||||
const friendlyMessage = friendlyZapErrorMessage(error);
|
||||
console.error('[post-interactions][nutzap] handleNutzapClick:failed', error);
|
||||
if (countEl) {
|
||||
countEl.textContent = timeoutFailure ? 'check' : 'fail';
|
||||
countEl.title = friendlyMessage;
|
||||
setTimeout(() => {
|
||||
updateNutzapButtonCount(itemEl, getPostState(postId));
|
||||
}, timeoutFailure ? 2800 : 2200);
|
||||
}
|
||||
window.alert(friendlyMessage);
|
||||
itemEl.classList.remove('zap-success');
|
||||
} finally {
|
||||
clearInterval(zapPulseInterval);
|
||||
itemEl.dataset.busy = '0';
|
||||
|
||||
if (!paymentSucceeded) {
|
||||
itemEl.classList.remove('active', 'zap-pending', 'zap-pending-accent', 'zap-success');
|
||||
updateIconState(iconContainer, 'nutzap', false);
|
||||
} else {
|
||||
itemEl.classList.remove('active', 'zap-pending-accent');
|
||||
updateIconState(iconContainer, 'nutzap', true);
|
||||
}
|
||||
|
||||
console.log('[post-interactions][nutzap] handleNutzapClick:finish');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the count display for an interaction item
|
||||
* @param {HTMLElement} itemEl - The interaction item element
|
||||
@@ -2308,6 +2798,8 @@ export function initInteractions(ndkFunctions = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -2325,6 +2817,8 @@ export function initInteractions(ndkFunctions = {}) {
|
||||
walletPayInvoiceFn = typeof walletPayInvoice === 'function' ? walletPayInvoice : null;
|
||||
walletSendNutzapFn = typeof walletSendNutzap === 'function' ? walletSendNutzap : null;
|
||||
walletFetchMintListFn = typeof walletFetchMintList === 'function' ? walletFetchMintList : null;
|
||||
walletGetMintsFn = typeof walletGetMints === 'function' ? walletGetMints : null;
|
||||
walletGetBalanceFn = typeof walletGetBalance === 'function' ? walletGetBalance : null;
|
||||
getRelayDataFn = typeof getRelayData === 'function' ? getRelayData : null;
|
||||
getUserSettingsFn = typeof getUserSettings === 'function' ? getUserSettings : null;
|
||||
patchUserSettingsFn = typeof patchUserSettings === 'function' ? patchUserSettings : null;
|
||||
|
||||
@@ -14,6 +14,8 @@ export function initPostCards(deps = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
maxSubscriptions = DEFAULT_MAX_SUBSCRIPTIONS,
|
||||
getUserSettings,
|
||||
@@ -34,6 +36,8 @@ export function initPostCards(deps = {}) {
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
@@ -65,7 +69,9 @@ export function initPostCards(deps = {}) {
|
||||
|
||||
function handleUpdate(postId, state, event, currentPubkey) {
|
||||
interactions.updateInteractionBar(postId, state);
|
||||
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId)) {
|
||||
// Only render inline comments if comments are visible (the new feed
|
||||
// sets commentsVisible=false to avoid inline reply rendering).
|
||||
if (event && event.kind === 1 && interactions.isCommentEvent?.(event, postId) && interactions.getCommentsVisible?.()) {
|
||||
interactions.addCommentToPost(postId, {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
|
||||
345
www/js/stego.mjs
Normal file
345
www/js/stego.mjs
Normal file
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* LLM-based steganography — JavaScript port of stego.py.
|
||||
*
|
||||
* Half-splitting entropy coding on top of GPT-2's next-token distribution.
|
||||
* Both encoder and decoder run the same JS code with the same PRNG seed,
|
||||
* so they stay synchronized regardless of cross-language PRNG differences.
|
||||
*/
|
||||
|
||||
import { Tensor } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.0";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRNG — mulberry32 (deterministic given seed)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function random() {
|
||||
a = (a + 0x6D2B79F5) >>> 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bit <-> string conversion (UTF-8)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function strToBits(s) {
|
||||
const data = new TextEncoder().encode(s);
|
||||
const bits = [];
|
||||
for (const byte of data) {
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
bits.push((byte >> i) & 1);
|
||||
}
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
export function bitsToStr(bits) {
|
||||
// Pad to a multiple of 8 (should already be).
|
||||
const padded = bits.slice();
|
||||
while (padded.length % 8 !== 0) padded.push(0);
|
||||
const bytes = new Uint8Array(Math.floor(padded.length / 8));
|
||||
for (let i = 0; i < padded.length; i += 8) {
|
||||
let byte = 0;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
byte = (byte << 1) | padded[i + j];
|
||||
}
|
||||
bytes[i / 8] = byte;
|
||||
}
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Softmax over a TypedArray / Array of logits (numerically stable)
|
||||
// ---------------------------------------------------------------------------
|
||||
function softmax(logits) {
|
||||
let max = -Infinity;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
if (logits[i] > max) max = logits[i];
|
||||
}
|
||||
const probs = new Float32Array(logits.length);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
const e = Math.exp(logits[i] - max);
|
||||
probs[i] = e;
|
||||
sum += e;
|
||||
}
|
||||
if (sum <= 0) sum = 1;
|
||||
for (let i = 0; i < logits.length; i++) {
|
||||
probs[i] /= sum;
|
||||
}
|
||||
return probs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next-token probability distribution from the model.
|
||||
// Returns a Float32Array of length vocab_size.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function nextTokenProbs(model, tokenizer, tokenIds) {
|
||||
// Build the model inputs directly from the token-id array. We must NOT
|
||||
// pass the id array through tokenizer(): in Transformers.js v3 the
|
||||
// tokenizer call expects a string and will fail with
|
||||
// "e.split is not a function" when given numbers. Instead we construct
|
||||
// int64 tensors ourselves and feed them straight to the model.
|
||||
const seqLength = tokenIds.length;
|
||||
const idData = BigInt64Array.from(tokenIds, (x) => BigInt(x));
|
||||
const maskData = BigInt64Array.from({ length: seqLength }, () => 1n);
|
||||
const inputs = {
|
||||
input_ids: new Tensor("int64", idData, [1, seqLength]),
|
||||
attention_mask: new Tensor("int64", maskData, [1, seqLength]),
|
||||
};
|
||||
const output = await model(inputs);
|
||||
const logits = output.logits;
|
||||
|
||||
// logits.dims = [batch, seq_len, vocab_size]
|
||||
const dims = logits.dims;
|
||||
const seqLen = dims[1];
|
||||
const vocabSize = dims[2];
|
||||
const data = logits.data;
|
||||
|
||||
// Extract the last token's logits: row = (batch=0, last token, :)
|
||||
const offset = (seqLen - 1) * vocabSize;
|
||||
const lastLogits = new Float32Array(vocabSize);
|
||||
for (let i = 0; i < vocabSize; i++) {
|
||||
lastLogits[i] = data[offset + i];
|
||||
}
|
||||
return softmax(lastLogits);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Split the sorted vocabulary into two halves at cumulative prob 0.50.
|
||||
// Returns { firstIds, firstProbs, secondIds, secondProbs }.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function splitHalves(probs) {
|
||||
const vocabSize = probs.length;
|
||||
|
||||
// Build an index array covering EVERY token id [0 .. vocabSize-1] and
|
||||
// sort it by probability descending. Sorting indices (rather than
|
||||
// dropping any) guarantees that all token ids are retained and that the
|
||||
// two slices below form a complete, disjoint partition of the vocabulary.
|
||||
// Ties are broken by ascending token id so the ordering is fully
|
||||
// deterministic — important under int8 quantization where many logits can
|
||||
// be exactly equal (an unstable sort could otherwise diverge between the
|
||||
// encoder and decoder passes).
|
||||
const sortedIds = new Array(vocabSize);
|
||||
for (let i = 0; i < vocabSize; i++) sortedIds[i] = i;
|
||||
sortedIds.sort((a, b) => {
|
||||
const diff = probs[b] - probs[a];
|
||||
if (diff !== 0) return diff;
|
||||
return a - b; // stable tie-break by token id
|
||||
});
|
||||
|
||||
// Find the split point: the first index at which the cumulative
|
||||
// probability mass reaches/exceeds 0.50. Everything up to and including
|
||||
// that token forms the first half; the remainder forms the second half.
|
||||
let cum = 0.0;
|
||||
let splitIdx = vocabSize; // default: all mass in first half (edge case)
|
||||
for (let i = 0; i < vocabSize; i++) {
|
||||
cum += probs[sortedIds[i]];
|
||||
if (cum >= 0.50) {
|
||||
splitIdx = i + 1; // include this token in the first half
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clamp so BOTH halves are guaranteed non-empty. The split index must be
|
||||
// at least 1 (first half non-empty) and at most vocabSize-1 (second half
|
||||
// non-empty). This handles degenerate distributions where a single token
|
||||
// already holds >= 0.50 of the mass, or where the mass never reaches 0.50.
|
||||
if (splitIdx < 1) splitIdx = 1;
|
||||
if (splitIdx > vocabSize - 1) splitIdx = vocabSize - 1;
|
||||
|
||||
// slice(0, splitIdx) + slice(splitIdx) ALWAYS covers the entire sorted
|
||||
// array, so every token id appears in exactly one half — none missing,
|
||||
// none duplicated. Ids are plain Numbers (0..vocabSize-1).
|
||||
const firstIds = sortedIds.slice(0, splitIdx);
|
||||
const secondIds = sortedIds.slice(splitIdx);
|
||||
const firstProbs = firstIds.map(id => probs[id]);
|
||||
const secondProbs = secondIds.map(id => probs[id]);
|
||||
|
||||
return { firstIds, firstProbs, secondIds, secondProbs };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample a token id from the given half using renormalized probabilities.
|
||||
// Advances the PRNG exactly once (one call to rng()).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function sampleWithinHalf(ids, probs, rng) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < probs.length; i++) total += probs[i];
|
||||
if (total <= 0) total = 1;
|
||||
const r = rng();
|
||||
let cum = 0.0;
|
||||
let chosen = ids[ids.length - 1];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
cum += probs[i] / total;
|
||||
if (r < cum) {
|
||||
chosen = ids[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encoder
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function encode(model, tokenizer, secretMessage, context, key, padTokens = 3, onToken = null) {
|
||||
const rng = mulberry32(key >>> 0);
|
||||
const bits = strToBits(secretMessage);
|
||||
const numBits = bits.length;
|
||||
const totalBits = numBits;
|
||||
const totalChars = secretMessage.length;
|
||||
|
||||
// Tokenize the context. Transformers.js tokenizer() returns an object
|
||||
// with input_ids; we extract the raw id array.
|
||||
const tokenIds = tokenizeToIds(tokenizer, context);
|
||||
|
||||
for (let bitIndex = 0; bitIndex < bits.length; bitIndex++) {
|
||||
const bit = bits[bitIndex];
|
||||
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
|
||||
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
|
||||
let chosen;
|
||||
if (bit === 0) {
|
||||
chosen = sampleWithinHalf(firstIds, firstProbs, rng);
|
||||
} else {
|
||||
chosen = sampleWithinHalf(secondIds, secondProbs, rng);
|
||||
}
|
||||
tokenIds.push(chosen);
|
||||
|
||||
// Stream the partial cover text back to the caller and yield to the
|
||||
// browser so the UI can repaint between (slow) forward passes.
|
||||
if (onToken) {
|
||||
const textSoFar = tokenizer.decode(tokenIds, { skip_special_tokens: true });
|
||||
const charIndex = Math.floor(bitIndex / 8) + 1;
|
||||
const currentChar = secretMessage[charIndex - 1] ?? "";
|
||||
onToken(textSoFar, bitIndex + 1, totalBits, currentChar, charIndex, totalChars);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// Padding tokens: sample from the full distribution using the same PRNG.
|
||||
for (let p = 0; p < padTokens; p++) {
|
||||
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
|
||||
// Sort descending.
|
||||
const pairs = [];
|
||||
for (let i = 0; i < probs.length; i++) pairs.push([probs[i], i]);
|
||||
pairs.sort((a, b) => b[0] - a[0]);
|
||||
const sp = pairs.map(x => x[0]);
|
||||
const si = pairs.map(x => x[1]);
|
||||
let total = 0;
|
||||
for (let i = 0; i < sp.length; i++) total += sp[i];
|
||||
if (total <= 0) total = 1;
|
||||
const r = rng();
|
||||
let cum = 0.0;
|
||||
let chosen = si[0];
|
||||
for (let i = 0; i < si.length; i++) {
|
||||
cum += sp[i] / total;
|
||||
if (r < cum) {
|
||||
chosen = si[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokenIds.push(chosen);
|
||||
}
|
||||
|
||||
const coverText = tokenizer.decode(tokenIds, { skip_special_tokens: true });
|
||||
return { coverText, numBits, tokenCount: tokenIds.length };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decoder
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function decode(model, tokenizer, coverText, context, key, numBits, padTokens = 3, onProgress = null) {
|
||||
const rng = mulberry32(key >>> 0);
|
||||
const totalBits = numBits;
|
||||
|
||||
const contextIds = tokenizeToIds(tokenizer, context);
|
||||
const coverIds = tokenizeToIds(tokenizer, coverText);
|
||||
|
||||
// Generated tokens are everything after the context prefix.
|
||||
const genIds = coverIds.slice(contextIds.length);
|
||||
|
||||
const tokenIds = contextIds.slice();
|
||||
const bits = [];
|
||||
|
||||
for (const genToken of genIds) {
|
||||
if (bits.length >= numBits) break;
|
||||
const probs = await nextTokenProbs(model, tokenizer, tokenIds);
|
||||
const { firstIds, firstProbs, secondIds, secondProbs } = splitHalves(probs);
|
||||
|
||||
// splitHalves yields plain Number ids and partitions the entire
|
||||
// vocabulary, so a normalized (Number) genToken is guaranteed to be in
|
||||
// exactly one half. tokenizeToIds normalizes ids to Number, but coerce
|
||||
// defensively here too in case a BigInt slips through.
|
||||
const token = typeof genToken === "bigint" ? Number(genToken) : genToken;
|
||||
const firstSet = new Set(firstIds);
|
||||
const secondSet = new Set(secondIds);
|
||||
|
||||
let recoveredBit;
|
||||
if (firstSet.has(token)) {
|
||||
recoveredBit = 0;
|
||||
bits.push(0);
|
||||
// Advance PRNG identically to the encoder.
|
||||
sampleWithinHalf(firstIds, firstProbs, rng);
|
||||
} else if (secondSet.has(token)) {
|
||||
recoveredBit = 1;
|
||||
bits.push(1);
|
||||
sampleWithinHalf(secondIds, secondProbs, rng);
|
||||
} else {
|
||||
throw new Error(`Generated token ${token} not found in either half.`);
|
||||
}
|
||||
tokenIds.push(token);
|
||||
|
||||
// Report progress and yield so the UI can repaint between passes.
|
||||
if (onProgress) {
|
||||
const currentToken = tokenizer.decode([token], { skip_special_tokens: true });
|
||||
const completedBytes = Math.floor(bits.length / 8);
|
||||
const recoveredChars = bitsToStr(bits.slice(0, completedBytes * 8));
|
||||
onProgress(
|
||||
bits.length,
|
||||
totalBits,
|
||||
recoveredChars,
|
||||
currentToken,
|
||||
recoveredBit,
|
||||
);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
return bitsToStr(bits.slice(0, numBits));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: tokenize a string to a plain array of token ids.
|
||||
// Handles the various shapes Transformers.js may return.
|
||||
// ---------------------------------------------------------------------------
|
||||
function tokenizeToIds(tokenizer, text) {
|
||||
const inputs = tokenizer(text, { add_special_tokens: false });
|
||||
let ids;
|
||||
if (inputs && inputs.input_ids) {
|
||||
ids = inputs.input_ids;
|
||||
} else if (Array.isArray(inputs)) {
|
||||
ids = inputs;
|
||||
} else {
|
||||
ids = inputs;
|
||||
}
|
||||
// ids may be a Tensor or a nested array like [[1,2,3]].
|
||||
let arr;
|
||||
if (ids && typeof ids.data !== "undefined" && ids.dims) {
|
||||
arr = Array.from(ids.data);
|
||||
} else if (Array.isArray(ids)) {
|
||||
// Flatten one level if nested: [[...]] -> [...]
|
||||
arr = (ids.length > 0 && Array.isArray(ids[0])) ? ids[0].slice() : ids.slice();
|
||||
} else {
|
||||
// Fallback: try to iterate.
|
||||
arr = Array.from(ids);
|
||||
}
|
||||
// CRITICAL: token tensors are int64, so ids.data is a BigInt64Array and
|
||||
// Array.from() yields BigInts (e.g. 351n). splitHalves produces plain
|
||||
// Number ids, and `new Set([...Numbers]).has(351n)` is false — which is
|
||||
// exactly what caused "Generated token 351 not found in either half".
|
||||
// Normalize every id to a plain Number so membership checks line up.
|
||||
return arr.map(v => (typeof v === "bigint" ? Number(v) : Number(v)));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.14",
|
||||
"VERSION_NUMBER": "0.7.14",
|
||||
"BUILD_DATE": "2026-05-27T19:48:34.590Z"
|
||||
"VERSION": "v0.7.87",
|
||||
"VERSION_NUMBER": "0.7.87",
|
||||
"BUILD_DATE": "2026-07-01T13:33:29.318Z"
|
||||
}
|
||||
|
||||
444
www/js/zaps.mjs
444
www/js/zaps.mjs
@@ -249,6 +249,37 @@ function closeActiveZapModal(result, resolver) {
|
||||
resolver(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe insertion into HTML text/attribute content.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
const HTML_ESCAPE_RE = /[&<>"']/g;
|
||||
function escapeHtml(value) {
|
||||
const amp = '&' + 'amp;';
|
||||
const lt = '&' + 'lt;';
|
||||
const gt = '&' + 'gt;';
|
||||
const quot = '&' + 'quot;';
|
||||
const apos = '&' + '#39;';
|
||||
const map = { '&': amp, '<': lt, '>': gt, '"': quot, "'": apos };
|
||||
return String(value || '').replace(HTML_ESCAPE_RE, (ch) => map[ch]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightning-only zap details dialog.
|
||||
*
|
||||
* Shows amount input, preset amounts, Cashu balance, and an insufficient-balance
|
||||
* warning. No rail selector — Lightning is the only rail here. Nutzaps have
|
||||
* their own dialog (`promptNutzapDetails`) and their own button.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {number} [options.defaultAmountSats=21]
|
||||
* @param {string} [options.defaultComment='']
|
||||
* @param {boolean} [options.defaultShouldZap=true]
|
||||
* @param {Function} [options.onSaveDefault]
|
||||
* @param {number|null} [options.balanceSats=null]
|
||||
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
|
||||
*/
|
||||
export function promptZapDetails(options = {}) {
|
||||
if (activeZapModalEl) {
|
||||
return Promise.resolve(null);
|
||||
@@ -258,7 +289,8 @@ export function promptZapDetails(options = {}) {
|
||||
defaultAmountSats = 21,
|
||||
defaultComment = '',
|
||||
defaultShouldZap = true,
|
||||
onSaveDefault = null
|
||||
onSaveDefault = null,
|
||||
balanceSats = null
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -268,11 +300,16 @@ export function promptZapDetails(options = {}) {
|
||||
: 21;
|
||||
const normalizedDefaultComment = String(defaultComment || '').trim();
|
||||
|
||||
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
|
||||
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
|
||||
: '';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'zap-selector-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send zap">
|
||||
<div class="zap-selector-title">Send Zap</div>
|
||||
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send Lightning zap">
|
||||
<div class="zap-selector-title">⚡ Lightning Zap</div>
|
||||
${balanceDisplay}
|
||||
<div class="zap-selector-presets">
|
||||
<button type="button" class="zap-preset" data-amount="21">⚡21</button>
|
||||
<button type="button" class="zap-preset" data-amount="100">⚡100</button>
|
||||
@@ -284,9 +321,10 @@ export function promptZapDetails(options = {}) {
|
||||
Amount (sats)
|
||||
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
|
||||
</label>
|
||||
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
|
||||
<label class="zap-selector-label">
|
||||
Comment (optional)
|
||||
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${normalizedDefaultComment.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</textarea>
|
||||
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post ⚡">${escapeHtml(normalizedDefaultComment)}</textarea>
|
||||
</label>
|
||||
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
|
||||
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
|
||||
@@ -307,6 +345,7 @@ export function promptZapDetails(options = {}) {
|
||||
const cancelBtn = overlay.querySelector('.zap-cancel');
|
||||
const enableZapEl = overlay.querySelector('.zap-selector-enable');
|
||||
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
|
||||
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
|
||||
|
||||
const parseCurrentValues = () => {
|
||||
const amountSats = Number(amountEl?.value || 0);
|
||||
@@ -315,11 +354,24 @@ export function promptZapDetails(options = {}) {
|
||||
return { amountSats, comment, shouldZap };
|
||||
};
|
||||
|
||||
const updateBalanceWarning = () => {
|
||||
if (!warningEl) return;
|
||||
const { amountSats } = parseCurrentValues();
|
||||
const balance = Number(balanceSats);
|
||||
const insufficient = Number.isFinite(balance)
|
||||
&& balance >= 0
|
||||
&& Number.isFinite(amountSats)
|
||||
&& amountSats > balance;
|
||||
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
|
||||
warningEl.style.display = insufficient ? '' : 'none';
|
||||
};
|
||||
|
||||
const selectPreset = (button) => {
|
||||
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
|
||||
if (amountEl && button?.dataset?.amount) {
|
||||
amountEl.value = button.dataset.amount;
|
||||
}
|
||||
updateBalanceWarning();
|
||||
};
|
||||
|
||||
presetButtons.forEach((button) => {
|
||||
@@ -328,6 +380,7 @@ export function promptZapDetails(options = {}) {
|
||||
|
||||
amountEl?.addEventListener('input', () => {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
updateBalanceWarning();
|
||||
});
|
||||
|
||||
saveDefaultBtn?.addEventListener('click', async () => {
|
||||
@@ -390,6 +443,222 @@ export function promptZapDetails(options = {}) {
|
||||
} else {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
updateBalanceWarning();
|
||||
amountEl?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nutzap (Cashu ecash) details dialog.
|
||||
*
|
||||
* Shows amount input, preset amounts, Cashu balance, the recipient's accepted
|
||||
* mints (from kind 10019), and which shared mint will be used to send the
|
||||
* ecash. Returns the user's choices or null if cancelled.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {number} [options.defaultAmountSats=21]
|
||||
* @param {string} [options.defaultComment='']
|
||||
* @param {boolean} [options.defaultShouldZap=true]
|
||||
* @param {Function} [options.onSaveDefault]
|
||||
* @param {number|null} [options.balanceSats=null]
|
||||
* @param {string[]} [options.recipientMints=[]] - all mints on recipient's kind 10019
|
||||
* @param {string[]} [options.sharedMints=[]] - mints shared with sender's wallet
|
||||
* @returns {Promise<{amountSats:number, comment:string, shouldZap:boolean}|null>}
|
||||
*/
|
||||
export function promptNutzapDetails(options = {}) {
|
||||
if (activeZapModalEl) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
const {
|
||||
defaultAmountSats = 21,
|
||||
defaultComment = '',
|
||||
defaultShouldZap = true,
|
||||
onSaveDefault = null,
|
||||
balanceSats = null,
|
||||
recipientMints = [],
|
||||
sharedMints = []
|
||||
} = options;
|
||||
|
||||
const normalizedRecipientMints = Array.isArray(recipientMints)
|
||||
? recipientMints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const normalizedSharedMints = Array.isArray(sharedMints)
|
||||
? sharedMints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const primarySharedMint = normalizedSharedMints[0] || '';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const initialAmount = Number(defaultAmountSats);
|
||||
const normalizedDefaultAmount = Number.isFinite(initialAmount) && initialAmount > 0
|
||||
? Math.floor(initialAmount)
|
||||
: 21;
|
||||
const normalizedDefaultComment = String(defaultComment || '').trim();
|
||||
|
||||
const balanceDisplay = (Number.isFinite(Number(balanceSats)) && Number(balanceSats) >= 0)
|
||||
? `<div class="zap-selector-balance">Cashu balance: ${Math.floor(Number(balanceSats))} sats</div>`
|
||||
: '';
|
||||
|
||||
const sharedMintInfoHtml = primarySharedMint
|
||||
? `<div class="zap-selector-mint-info">Will send via ${escapeHtml(primarySharedMint)}</div>`
|
||||
: '';
|
||||
|
||||
const recipientMintsListHtml = normalizedRecipientMints.length > 0
|
||||
? `<div class="zap-selector-mint-info">
|
||||
<div>Recipient accepts mints:</div>
|
||||
<ul class="zap-selector-mint-list">
|
||||
${normalizedRecipientMints.map((m) => {
|
||||
const isShared = normalizedSharedMints.some(
|
||||
(s) => s.replace(/\/+$/, '').toLowerCase() === m.replace(/\/+$/, '').toLowerCase()
|
||||
);
|
||||
return `<li class="zap-selector-mint-list-item${isShared ? ' shared' : ''}">${escapeHtml(m)}${isShared ? ' ✓' : ''}</li>`;
|
||||
}).join('')}
|
||||
</ul>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'zap-selector-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="zap-selector" role="dialog" aria-modal="true" aria-label="Send nutzap">
|
||||
<div class="zap-selector-title">🥜 Nutzap</div>
|
||||
${balanceDisplay}
|
||||
<div class="zap-selector-presets">
|
||||
<button type="button" class="zap-preset" data-amount="21">🥜21</button>
|
||||
<button type="button" class="zap-preset" data-amount="100">🥜100</button>
|
||||
<button type="button" class="zap-preset" data-amount="500">🥜500</button>
|
||||
<button type="button" class="zap-preset" data-amount="1000">🥜1K</button>
|
||||
<button type="button" class="zap-preset" data-amount="5000">🥜5K</button>
|
||||
</div>
|
||||
<label class="zap-selector-label">
|
||||
Amount (sats)
|
||||
<input class="zap-selector-amount" type="number" min="1" step="1" value="${normalizedDefaultAmount}" />
|
||||
</label>
|
||||
<div class="zap-selector-warning" data-warning="insufficient-balance"></div>
|
||||
${sharedMintInfoHtml}
|
||||
${recipientMintsListHtml}
|
||||
<label class="zap-selector-label">
|
||||
Comment (optional)
|
||||
<textarea class="zap-selector-comment" rows="3" placeholder="Nice post 🥜">${escapeHtml(normalizedDefaultComment)}</textarea>
|
||||
</label>
|
||||
<label class="zap-selector-label" style="display:flex;align-items:center;gap:8px;">
|
||||
<input class="zap-selector-enable" type="checkbox" ${defaultShouldZap ? 'checked' : ''} />
|
||||
Send nutzap
|
||||
</label>
|
||||
<div class="zap-selector-actions">
|
||||
<button type="button" class="zap-cancel">Cancel</button>
|
||||
<button type="button" class="zap-save-default">Save as default</button>
|
||||
<button type="button" class="zap-send">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const amountEl = overlay.querySelector('.zap-selector-amount');
|
||||
const commentEl = overlay.querySelector('.zap-selector-comment');
|
||||
const sendBtn = overlay.querySelector('.zap-send');
|
||||
const saveDefaultBtn = overlay.querySelector('.zap-save-default');
|
||||
const cancelBtn = overlay.querySelector('.zap-cancel');
|
||||
const enableZapEl = overlay.querySelector('.zap-selector-enable');
|
||||
const presetButtons = Array.from(overlay.querySelectorAll('.zap-preset'));
|
||||
const warningEl = overlay.querySelector('[data-warning="insufficient-balance"]');
|
||||
|
||||
const parseCurrentValues = () => {
|
||||
const amountSats = Number(amountEl?.value || 0);
|
||||
const comment = String(commentEl?.value || '').trim();
|
||||
const shouldZap = Boolean(enableZapEl?.checked);
|
||||
return { amountSats, comment, shouldZap };
|
||||
};
|
||||
|
||||
const updateBalanceWarning = () => {
|
||||
if (!warningEl) return;
|
||||
const { amountSats } = parseCurrentValues();
|
||||
const balance = Number(balanceSats);
|
||||
const insufficient = Number.isFinite(balance)
|
||||
&& balance >= 0
|
||||
&& Number.isFinite(amountSats)
|
||||
&& amountSats > balance;
|
||||
warningEl.textContent = insufficient ? 'Insufficient balance' : '';
|
||||
warningEl.style.display = insufficient ? '' : 'none';
|
||||
};
|
||||
|
||||
const selectPreset = (button) => {
|
||||
presetButtons.forEach((btn) => btn.classList.toggle('active', btn === button));
|
||||
if (amountEl && button?.dataset?.amount) {
|
||||
amountEl.value = button.dataset.amount;
|
||||
}
|
||||
updateBalanceWarning();
|
||||
};
|
||||
|
||||
presetButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => selectPreset(button));
|
||||
});
|
||||
|
||||
amountEl?.addEventListener('input', () => {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
updateBalanceWarning();
|
||||
});
|
||||
|
||||
saveDefaultBtn?.addEventListener('click', async () => {
|
||||
const { amountSats, comment } = parseCurrentValues();
|
||||
if (!Number.isFinite(amountSats) || amountSats <= 0) {
|
||||
amountEl?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onSaveDefault === 'function') {
|
||||
try {
|
||||
await onSaveDefault({ amountSats, comment });
|
||||
saveDefaultBtn.textContent = 'Saved';
|
||||
setTimeout(() => {
|
||||
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
||||
}, 1200);
|
||||
} catch (error) {
|
||||
console.error('[zaps] Save as default failed:', error?.message || error, error);
|
||||
saveDefaultBtn.textContent = 'Save failed';
|
||||
setTimeout(() => {
|
||||
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
||||
}, 1200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[zaps] Save as default unavailable: onSaveDefault callback not provided');
|
||||
saveDefaultBtn.textContent = 'Unavailable';
|
||||
setTimeout(() => {
|
||||
if (saveDefaultBtn) saveDefaultBtn.textContent = 'Save as default';
|
||||
}, 1200);
|
||||
});
|
||||
|
||||
sendBtn?.addEventListener('click', () => {
|
||||
const { amountSats, comment, shouldZap } = parseCurrentValues();
|
||||
if (shouldZap && (!Number.isFinite(amountSats) || amountSats <= 0)) {
|
||||
amountEl?.focus();
|
||||
return;
|
||||
}
|
||||
closeActiveZapModal({ amountSats, comment, shouldZap }, resolve);
|
||||
});
|
||||
|
||||
cancelBtn?.addEventListener('click', () => closeActiveZapModal(null, resolve));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) closeActiveZapModal(null, resolve);
|
||||
});
|
||||
|
||||
const onEscape = (event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
closeActiveZapModal(null, resolve);
|
||||
};
|
||||
document.addEventListener('keydown', onEscape);
|
||||
|
||||
activeZapModalEl = overlay;
|
||||
document.body.appendChild(overlay);
|
||||
const defaultPreset = presetButtons.find((btn) => Number(btn.dataset?.amount || 0) === normalizedDefaultAmount);
|
||||
if (defaultPreset) {
|
||||
selectPreset(defaultPreset);
|
||||
} else {
|
||||
presetButtons.forEach((btn) => btn.classList.remove('active'));
|
||||
}
|
||||
updateBalanceWarning();
|
||||
amountEl?.focus();
|
||||
});
|
||||
}
|
||||
@@ -506,3 +775,170 @@ export function extractZapComment(event) {
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ZAP CAPABILITY RESOLUTION (Phase 3 — feed-upgrade)
|
||||
// =============================================================================
|
||||
//
|
||||
// resolveZapCapabilities(pubkey) determines, at a glance, which zap rails are
|
||||
// available for a given recipient pubkey:
|
||||
// - canLightning: recipient has a lud16 Lightning address (NIP-57 via Cashu melt)
|
||||
// - canNutzap: recipient has a kind 10019 mint list (NIP-61) AND at least
|
||||
// one of those mints overlaps with the sender's wallet mints
|
||||
// - sharedMints: the overlapping mint URLs (empty when no overlap)
|
||||
// - nutzapMints: all mints declared on the recipient's kind 10019
|
||||
// - lud16: the recipient's Lightning address (or null)
|
||||
//
|
||||
// Results are cached per-pubkey in an in-memory Map with a 5-minute TTL so
|
||||
// repeated feed renders do not refetch. The cache is best-effort and safe to
|
||||
// clear on page navigation (see clearZapCapabilitiesCache).
|
||||
|
||||
const ZAP_CAP_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const zapCapCache = new Map(); // pubkey -> { result, expiresAt }
|
||||
|
||||
/**
|
||||
* Clear the in-memory zap-capability cache.
|
||||
* Safe to call on page navigation; subsequent calls will refetch.
|
||||
*/
|
||||
export function clearZapCapabilitiesCache() {
|
||||
zapCapCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mint URL for comparison (trim + strip trailing slashes).
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeMintForCompare(url) {
|
||||
return String(url || '').trim().replace(/\/+$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which zap rails are available for a given pubkey.
|
||||
*
|
||||
* @param {string} pubkey - The recipient's pubkey (hex)
|
||||
* @param {Object} options
|
||||
* @param {Function} [options.fetchProfile] - profile-cache fetcher (kind 0)
|
||||
* @param {Function} [options.fetchMintList] - walletFetchMintList(pubkey) -> { hasMintList, mints, ... }
|
||||
* @param {Function} [options.getSenderMints] - walletGetMints() -> { mints: [...] }
|
||||
* @param {boolean} [options.useCache=true] - whether to consult the in-memory cache
|
||||
* @param {string} [options.logPrefix='[zaps]']
|
||||
* @returns {Promise<{canLightning:boolean, canNutzap:boolean, sharedMints:string[], nutzapMints:string[], lud16:string|null, hasMintList:boolean}>}
|
||||
*/
|
||||
export async function resolveZapCapabilities(pubkey, options = {}) {
|
||||
const {
|
||||
fetchProfile: fetchProfileFn,
|
||||
fetchMintList,
|
||||
getSenderMints,
|
||||
useCache = true,
|
||||
logPrefix = '[zaps]'
|
||||
} = options;
|
||||
|
||||
const recipient = String(pubkey || '').trim();
|
||||
if (!recipient) {
|
||||
return {
|
||||
canLightning: false,
|
||||
canNutzap: false,
|
||||
sharedMints: [],
|
||||
nutzapMints: [],
|
||||
lud16: null,
|
||||
hasMintList: false
|
||||
};
|
||||
}
|
||||
|
||||
// --- Cache lookup ---------------------------------------------------------
|
||||
const now = Date.now();
|
||||
if (useCache) {
|
||||
const cached = zapCapCache.get(recipient);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.result;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Lightning capability (lud16 from kind 0) -----------------------------
|
||||
let lud16 = null;
|
||||
let canLightning = false;
|
||||
if (typeof fetchProfileFn === 'function') {
|
||||
try {
|
||||
const profile = await fetchProfileFn(recipient);
|
||||
const normalized = normalizeLud16(profile?.lud16);
|
||||
if (normalized) {
|
||||
lud16 = normalized;
|
||||
canLightning = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${logPrefix} resolveZapCapabilities: profile fetch failed`, error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Nutzap capability (kind 10019 + shared mint overlap) -----------------
|
||||
let nutzapMints = [];
|
||||
let sharedMints = [];
|
||||
let canNutzap = false;
|
||||
let hasMintList = false;
|
||||
|
||||
if (typeof fetchMintList === 'function') {
|
||||
try {
|
||||
const mintList = await fetchMintList(recipient);
|
||||
const rawMints = Array.isArray(mintList?.mints)
|
||||
? mintList.mints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
nutzapMints = Array.from(new Set(rawMints));
|
||||
hasMintList = Boolean(mintList?.hasMintList) && nutzapMints.length > 0;
|
||||
|
||||
if (hasMintList) {
|
||||
// Determine the sender's wallet mints to compute overlap.
|
||||
let senderMints = [];
|
||||
if (typeof getSenderMints === 'function') {
|
||||
try {
|
||||
const senderResult = await getSenderMints();
|
||||
senderMints = Array.isArray(senderResult?.mints)
|
||||
? senderResult.mints.map((m) => String(m || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
} catch (error) {
|
||||
console.warn(`${logPrefix} resolveZapCapabilities: sender mints fetch failed`, error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
if (senderMints.length > 0) {
|
||||
const senderSet = new Set(senderMints.map(normalizeMintForCompare));
|
||||
sharedMints = nutzapMints.filter((m) => senderSet.has(normalizeMintForCompare(m)));
|
||||
canNutzap = sharedMints.length > 0;
|
||||
} else {
|
||||
// No sender wallet mints available yet — cannot confirm a shared mint,
|
||||
// so we do not claim canNutzap. The recipient still has a mint list,
|
||||
// which the UI may render in a muted state.
|
||||
canNutzap = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`${logPrefix} resolveZapCapabilities: mint list fetch failed`, error?.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
canLightning,
|
||||
canNutzap,
|
||||
sharedMints,
|
||||
nutzapMints,
|
||||
lud16,
|
||||
hasMintList
|
||||
};
|
||||
|
||||
// --- Cache store ----------------------------------------------------------
|
||||
zapCapCache.set(recipient, {
|
||||
result,
|
||||
expiresAt: Date.now() + ZAP_CAP_CACHE_TTL_MS
|
||||
});
|
||||
|
||||
console.log(`${logPrefix} resolveZapCapabilities:ok`, {
|
||||
recipient: recipient.slice(0, 8) + '…',
|
||||
canLightning,
|
||||
canNutzap,
|
||||
hasMintList,
|
||||
sharedMints: sharedMints.length,
|
||||
nutzapMints: nutzapMints.length
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
1872
www/llm-steganography.html
Normal file
1872
www/llm-steganography.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -457,7 +457,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
5600
www/music-greyscale.html
Normal file
5600
www/music-greyscale.html
Normal file
File diff suppressed because it is too large
Load Diff
398
www/music.html
398
www/music.html
@@ -221,6 +221,38 @@
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
|
||||
#musicUploadSection {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
#musicUploadSection input,
|
||||
#musicUploadSection button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
background: var(--secondary-color);
|
||||
color: var(--primary-color);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#musicUploadSection button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#musicUploadStatus {
|
||||
font-size: 11px;
|
||||
color: var(--muted-color);
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
#musicPlaylistProfile {
|
||||
border: var(--border);
|
||||
border-radius: var(--border-radius);
|
||||
@@ -1136,7 +1168,10 @@
|
||||
<div class="musicSidebarTitle">MY PLAYLISTS</div>
|
||||
<ul id="musicMyPlaylistsList" class="musicPlaylistList"></ul>
|
||||
|
||||
<div class="musicSidebarTitle">FRIENDS</div>
|
||||
<div class="musicSidebarTitle" style="display:flex; align-items:center; justify-content:space-between; gap:8px;">
|
||||
<span>FRIENDS</span>
|
||||
<button id="musicLoadFriendsBtn" class="btn musicMiniBtn" type="button">Load</button>
|
||||
</div>
|
||||
<ul id="musicFriendPlaylistsList" class="musicPlaylistList"></ul>
|
||||
|
||||
<div class="musicSidebarTitle">AI IMPORT</div>
|
||||
@@ -1152,6 +1187,16 @@
|
||||
<div id="musicImportStatus"></div>
|
||||
<div id="musicImportResults"></div>
|
||||
</section>
|
||||
|
||||
<div class="musicSidebarTitle">UPLOAD TRACK</div>
|
||||
<section id="musicUploadSection">
|
||||
<input id="musicUploadFile" type="file" accept="audio/*" />
|
||||
<input id="musicUploadTitle" type="text" placeholder="Title (optional)" />
|
||||
<input id="musicUploadArtist" type="text" placeholder="Artist (optional)" />
|
||||
<input id="musicUploadAlbum" type="text" placeholder="Album (optional)" />
|
||||
<button id="musicUploadBtn" class="btn" type="button">Upload to Gruuv</button>
|
||||
<div id="musicUploadStatus">Sign in to upload.</div>
|
||||
</section>
|
||||
</aside>
|
||||
<div class="musicGutter resizableColumnsGutter" data-gutter-index="0" aria-hidden="true"></div>
|
||||
|
||||
@@ -1333,7 +1378,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
@@ -1368,16 +1413,17 @@
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
import { GruuvAPI } from './js/gruuv-api.mjs';
|
||||
import { SimplePlayer } from './js/greyscale-player.mjs';
|
||||
import { sendAiChatJson } from './js/ai-chat.mjs';
|
||||
import { uploadGruuvTrack } from './js/gruuv-upload.mjs';
|
||||
import {
|
||||
PLAYLIST_KIND,
|
||||
buildPlaylistEvent,
|
||||
createPlaylistIdentifier,
|
||||
parsePlaylistEvent,
|
||||
isMusicPlaylistEvent,
|
||||
} from './js/greyscale-playlists.mjs';
|
||||
} from './js/gruuv-playlists.mjs';
|
||||
import { mountDotMenu } from './js/dot-menu.mjs';
|
||||
import { initResizableColumns } from './js/resizable-columns.mjs';
|
||||
import {
|
||||
@@ -1466,6 +1512,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
playlistDescriptionInput: document.getElementById('musicPlaylistDescriptionInput'),
|
||||
myPlaylistsList: document.getElementById('musicMyPlaylistsList'),
|
||||
friendPlaylistsList: document.getElementById('musicFriendPlaylistsList'),
|
||||
loadFriendsBtn: document.getElementById('musicLoadFriendsBtn'),
|
||||
importDropZone: document.getElementById('musicImportDropZone'),
|
||||
importInput: document.getElementById('musicImportInput'),
|
||||
importProcessing: document.getElementById('musicImportProcessing'),
|
||||
@@ -1483,9 +1530,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
viewerPubkeyInput: document.getElementById('musicViewerPubkeyInput'),
|
||||
viewerLoadBtn: document.getElementById('musicViewerLoadBtn'),
|
||||
viewerInfo: document.getElementById('musicViewerInfo'),
|
||||
uploadFileInput: document.getElementById('musicUploadFile'),
|
||||
uploadTitleInput: document.getElementById('musicUploadTitle'),
|
||||
uploadArtistInput: document.getElementById('musicUploadArtist'),
|
||||
uploadAlbumInput: document.getElementById('musicUploadAlbum'),
|
||||
uploadBtn: document.getElementById('musicUploadBtn'),
|
||||
uploadStatus: document.getElementById('musicUploadStatus'),
|
||||
};
|
||||
|
||||
const musicApi = new GreyscaleAPI();
|
||||
const musicApi = new GruuvAPI();
|
||||
const musicPlayer = new SimplePlayer({
|
||||
audio: musicEls.audio,
|
||||
progressEl: musicEls.progress,
|
||||
@@ -1502,6 +1555,8 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
|
||||
const myPlaylists = new Map();
|
||||
const friendPlaylists = new Map();
|
||||
let friendsFeedEnabled = false;
|
||||
let playlistSubscription = null;
|
||||
let musicQueue = [];
|
||||
let queueCurrentIndex = -1;
|
||||
let musicResultsMode = 'search';
|
||||
@@ -1534,6 +1589,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
let pendingShareUrl = '';
|
||||
let pendingShareArtUrl = '';
|
||||
let pendingShareLabel = '';
|
||||
let isGruuvUploadRunning = false;
|
||||
|
||||
const BLANK_IMAGE_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
|
||||
const MUSIC_COLUMN_STORAGE_KEY = 'music-column-widths:v1';
|
||||
@@ -1605,6 +1661,76 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
musicEls.status.dataset.type = type;
|
||||
}
|
||||
|
||||
function setUploadStatus(text) {
|
||||
if (!musicEls.uploadStatus) return;
|
||||
musicEls.uploadStatus.textContent = String(text || '').trim();
|
||||
}
|
||||
|
||||
function updateUploadUiState() {
|
||||
const canUpload = Boolean(isAuthenticated && currentPubkey);
|
||||
if (musicEls.uploadBtn) {
|
||||
musicEls.uploadBtn.disabled = !canUpload || isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadFileInput) {
|
||||
musicEls.uploadFileInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadTitleInput) {
|
||||
musicEls.uploadTitleInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadArtistInput) {
|
||||
musicEls.uploadArtistInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
if (musicEls.uploadAlbumInput) {
|
||||
musicEls.uploadAlbumInput.disabled = isGruuvUploadRunning;
|
||||
}
|
||||
|
||||
if (!canUpload && !isGruuvUploadRunning) {
|
||||
setUploadStatus('Sign in to upload.');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGruuvUploadClick() {
|
||||
if (isGruuvUploadRunning) return;
|
||||
|
||||
try {
|
||||
await promptLoginIfNeeded();
|
||||
} catch (error) {
|
||||
setUploadStatus(`Login required: ${error?.message || 'Unknown error'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = musicEls.uploadFileInput?.files?.[0];
|
||||
if (!file) {
|
||||
setUploadStatus('Choose an audio file first.');
|
||||
return;
|
||||
}
|
||||
|
||||
isGruuvUploadRunning = true;
|
||||
updateUploadUiState();
|
||||
setUploadStatus(`Uploading ${file.name}...`);
|
||||
|
||||
try {
|
||||
const result = await uploadGruuvTrack(file, {
|
||||
title: musicEls.uploadTitleInput?.value?.trim() || '',
|
||||
artist: musicEls.uploadArtistInput?.value?.trim() || '',
|
||||
album: musicEls.uploadAlbumInput?.value?.trim() || '',
|
||||
});
|
||||
|
||||
const title = result?.event?.tags?.find((t) => t?.[0] === 'title')?.[1] || file.name;
|
||||
setUploadStatus(`Uploaded: ${title}`);
|
||||
setMusicStatus(`Uploaded track to Gruuv: ${title}`, 'ok');
|
||||
|
||||
if (musicEls.uploadFileInput) musicEls.uploadFileInput.value = '';
|
||||
} catch (error) {
|
||||
console.error('[music upload] upload failed', error);
|
||||
setUploadStatus(`Upload failed: ${error?.message || 'Unknown error'}`);
|
||||
setMusicStatus(`Upload failed: ${error?.message || 'Unknown error'}`, 'error');
|
||||
} finally {
|
||||
isGruuvUploadRunning = false;
|
||||
updateUploadUiState();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAuthModeFromUrl() {
|
||||
const explicitAuth = getExplicitAuthModeFromUrl();
|
||||
if (explicitAuth) return explicitAuth;
|
||||
@@ -1687,6 +1813,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
if (musicEls.playlistNameInput) {
|
||||
musicEls.playlistNameInput.disabled = isReadOnlyTargetMode();
|
||||
}
|
||||
updateUploadUiState();
|
||||
}
|
||||
|
||||
async function initializeAuthentication(mode) {
|
||||
@@ -1743,6 +1870,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
await initializeAuthenticatedPageFeatures();
|
||||
syncLoggedInNpubToUrl();
|
||||
updateViewerControls();
|
||||
updateUploadUiState();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1839,7 +1967,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
function normalizeQueueTrack(track) {
|
||||
if (!track || typeof track !== 'object') return null;
|
||||
return {
|
||||
id: track.id,
|
||||
id: String(track.id || ''),
|
||||
title: track.title || 'Unknown title',
|
||||
artist: track.artist || 'Unknown artist',
|
||||
duration: track.duration || 0,
|
||||
@@ -1847,6 +1975,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
albumTitle: track.albumTitle || '',
|
||||
albumId: track.albumId || track.album?.id || track.raw?.album?.id || '',
|
||||
artistId: track.artistId || track.artist?.id || track.raw?.artist?.id || track.raw?.artists?.[0]?.id || '',
|
||||
streamUrl: track.streamUrl || track.raw?.streamUrl || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2405,6 +2534,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
}
|
||||
|
||||
function renderFriendPlaylists() {
|
||||
if (!friendsFeedEnabled && !isExternalTargetMode()) {
|
||||
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
|
||||
if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Load';
|
||||
musicEls.friendPlaylistsList.innerHTML = '<li class="musicPlaylistMeta">Friend playlists are paused. Click Load to fetch.</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (musicEls.loadFriendsBtn) musicEls.loadFriendsBtn.textContent = 'Loaded';
|
||||
const items = Array.from(friendPlaylists.values()).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
||||
if (!items.length) {
|
||||
destroyMountedMenuInstances(mountedMusicMenus.friendPlaylists);
|
||||
@@ -2712,14 +2849,20 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
return false;
|
||||
}
|
||||
|
||||
const exists = (playlist.tracks || []).some((item) => Number(item?.id) === Number(track?.id));
|
||||
const trackId = String(track?.id || '').trim();
|
||||
if (!trackId) {
|
||||
if (!silent) setMusicStatus('Track is missing an id.', 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const exists = (playlist.tracks || []).some((item) => String(item?.id || '').trim() === trackId);
|
||||
if (exists) {
|
||||
if (!silent) setMusicStatus('Track already in playlist.', 'neutral');
|
||||
return false;
|
||||
}
|
||||
|
||||
playlist.tracks.push({
|
||||
id: track.id,
|
||||
id: trackId,
|
||||
title: track.title || '',
|
||||
artist: track.artist || '',
|
||||
duration: track.duration || 0,
|
||||
@@ -3177,13 +3320,13 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIds = new Set((playlist.tracks || []).map((track) => Number(track?.id)).filter((id) => Number.isFinite(id)));
|
||||
const existingIds = new Set((playlist.tracks || []).map((track) => String(track?.id || '').trim()).filter(Boolean));
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const track of importMatchedTracks) {
|
||||
const id = Number(track?.id);
|
||||
if (!Number.isFinite(id) || existingIds.has(id)) {
|
||||
const id = String(track?.id || '').trim();
|
||||
if (!id || existingIds.has(id)) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -3242,55 +3385,192 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
clearPlaylistDropIndicator();
|
||||
}
|
||||
|
||||
function hydratePlaylistFromEvent(event, isFriend = false) {
|
||||
async function resolveTracksByAddresses(addresses = []) {
|
||||
const ids = Array.isArray(addresses) ? addresses.filter(Boolean) : [];
|
||||
if (!ids.length) return new Map();
|
||||
|
||||
if (musicApi && typeof musicApi.getTracksByAddresses === 'function') {
|
||||
return musicApi.getTracksByAddresses(ids);
|
||||
}
|
||||
|
||||
console.warn('[music] Falling back to coordinate search: musicApi.getTracksByAddresses() unavailable');
|
||||
const map = new Map();
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const result = await musicApi.searchTracks(id);
|
||||
const items = Array.isArray(result?.items) ? result.items : [];
|
||||
const match = items.find((item) => String(item?.id || '').trim() === id) || items[0] || null;
|
||||
if (match) {
|
||||
const prepared = musicApi.prepareTrack(match) || match;
|
||||
map.set(id, prepared);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[music] resolveTracksByAddresses fallback search failed', { id, error });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function hydratePlaylistFromEvent(event, isFriend = false) {
|
||||
const parsed = parsePlaylistEvent(event);
|
||||
if (!parsed || !isMusicPlaylistEvent(event)) return;
|
||||
|
||||
if (isFriend) {
|
||||
const key = getFriendPlaylistRouteKey(parsed);
|
||||
if (key) friendPlaylists.set(key, parsed);
|
||||
renderFriendPlaylists();
|
||||
} else {
|
||||
const existing = myPlaylists.get(parsed.identifier);
|
||||
myPlaylists.set(parsed.identifier, {
|
||||
identifier: parsed.identifier,
|
||||
title: parsed.title,
|
||||
description: parsed.description,
|
||||
image: parsed.image,
|
||||
banner: parsed.banner || '',
|
||||
tracks: parsed.tracks,
|
||||
pubkey: parsed.pubkey,
|
||||
eventId: parsed.eventId,
|
||||
createdAt: parsed.createdAt,
|
||||
raw: parsed.raw,
|
||||
...(existing || {}),
|
||||
});
|
||||
savePlaylistsLocal();
|
||||
renderMyPlaylists();
|
||||
}
|
||||
const commitPlaylist = (playlist, expectedEventId = '') => {
|
||||
const shouldSkipAsStale = (existing) => {
|
||||
if (!existing) return false;
|
||||
const existingCreatedAt = Number(existing?.createdAt || 0);
|
||||
const incomingCreatedAt = Number(playlist?.createdAt || 0);
|
||||
const existingEventId = String(existing?.eventId || '').trim();
|
||||
const incomingEventId = String(playlist?.eventId || '').trim();
|
||||
|
||||
if (
|
||||
expectedEventId
|
||||
&& existingEventId
|
||||
&& existingEventId !== expectedEventId
|
||||
&& existingCreatedAt >= incomingCreatedAt
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!expectedEventId
|
||||
&& existingEventId
|
||||
&& incomingEventId
|
||||
&& existingEventId !== incomingEventId
|
||||
&& existingCreatedAt > incomingCreatedAt
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
|
||||
if (urlEpisode && parsed.identifier === urlEpisode && !selectedPlaylistId) {
|
||||
if (isFriend) {
|
||||
const key = getFriendPlaylistRouteKey(parsed);
|
||||
if (key) openFriendPlaylistByKey(key);
|
||||
const key = getFriendPlaylistRouteKey(playlist);
|
||||
if (!key) return;
|
||||
const existing = friendPlaylists.get(key) || null;
|
||||
if (shouldSkipAsStale(existing)) return;
|
||||
friendPlaylists.set(key, playlist);
|
||||
renderFriendPlaylists();
|
||||
} else {
|
||||
openMyPlaylistByIdentifier(parsed.identifier);
|
||||
}
|
||||
}
|
||||
const existing = myPlaylists.get(playlist.identifier) || null;
|
||||
if (shouldSkipAsStale(existing)) return;
|
||||
|
||||
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === parsed.identifier) {
|
||||
activePlaylistView = { ...activePlaylistView, playlist: parsed };
|
||||
renderPlaylistProfileCard();
|
||||
myPlaylists.set(playlist.identifier, {
|
||||
...(existing || {}),
|
||||
identifier: playlist.identifier,
|
||||
title: playlist.title,
|
||||
description: playlist.description,
|
||||
image: playlist.image,
|
||||
banner: playlist.banner || '',
|
||||
tracks: playlist.tracks,
|
||||
pubkey: playlist.pubkey,
|
||||
eventId: playlist.eventId,
|
||||
createdAt: playlist.createdAt,
|
||||
raw: playlist.raw,
|
||||
});
|
||||
savePlaylistsLocal();
|
||||
renderMyPlaylists();
|
||||
}
|
||||
|
||||
const activeKey = getFriendPlaylistRouteKey(playlist);
|
||||
const shouldRefreshVisibleResults = (
|
||||
(musicResultsMode === 'my-playlist' && musicResultsPlaylistId === playlist.identifier)
|
||||
|| (musicResultsMode === 'friend-playlist' && !!activeKey && musicResultsPlaylistId === activeKey)
|
||||
);
|
||||
if (shouldRefreshVisibleResults) {
|
||||
musicTracks = Array.isArray(playlist.tracks) ? [...playlist.tracks] : [];
|
||||
renderMusicResults(musicTracks);
|
||||
}
|
||||
|
||||
const urlEpisode = String(getEpisodeFromUrl() || '').trim();
|
||||
if (urlEpisode && playlist.identifier === urlEpisode && !selectedPlaylistId) {
|
||||
if (isFriend) {
|
||||
const key = getFriendPlaylistRouteKey(playlist);
|
||||
if (key) openFriendPlaylistByKey(key);
|
||||
} else {
|
||||
openMyPlaylistByIdentifier(playlist.identifier);
|
||||
}
|
||||
}
|
||||
|
||||
if (activePlaylistView?.scope && activePlaylistView?.playlist?.identifier === playlist.identifier) {
|
||||
activePlaylistView = { ...activePlaylistView, playlist };
|
||||
renderPlaylistProfileCard();
|
||||
}
|
||||
};
|
||||
|
||||
commitPlaylist(parsed);
|
||||
|
||||
const trackIds = Array.isArray(parsed.trackIds) ? parsed.trackIds.filter(Boolean) : [];
|
||||
console.log('[music] hydratePlaylistFromEvent parsed', {
|
||||
identifier: parsed.identifier,
|
||||
eventId: parsed.eventId,
|
||||
createdAt: parsed.createdAt,
|
||||
trackIdsCount: trackIds.length,
|
||||
trackIds,
|
||||
});
|
||||
if (!trackIds.length) return;
|
||||
|
||||
try {
|
||||
const resolvedByAddress = await resolveTracksByAddresses(trackIds);
|
||||
console.log('[music] hydratePlaylistFromEvent resolved', {
|
||||
identifier: parsed.identifier,
|
||||
requested: trackIds.length,
|
||||
resolved: resolvedByAddress?.size || 0,
|
||||
});
|
||||
const hydratedTracks = trackIds.map((id, index) => {
|
||||
const base = parsed.tracks?.[index] || { id };
|
||||
const resolved = resolvedByAddress?.get?.(id) || null;
|
||||
if (resolved) {
|
||||
const prepared = musicApi.prepareTrack(resolved) || resolved;
|
||||
return {
|
||||
...base,
|
||||
...prepared,
|
||||
id,
|
||||
unavailable: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
id,
|
||||
title: String(base?.title || '').trim() || 'Unavailable track',
|
||||
artist: String(base?.artist || '').trim() || 'Unknown artist',
|
||||
duration: Number(base?.duration) || 0,
|
||||
cover: String(base?.cover || '').trim(),
|
||||
albumTitle: String(base?.albumTitle || '').trim(),
|
||||
streamUrl: String(base?.streamUrl || '').trim(),
|
||||
unavailable: true,
|
||||
};
|
||||
});
|
||||
|
||||
commitPlaylist({
|
||||
...parsed,
|
||||
tracks: hydratedTracks,
|
||||
}, parsed.eventId || '');
|
||||
} catch (error) {
|
||||
console.warn('[music] Failed to hydrate playlist tracks from a-tags', {
|
||||
playlist: parsed.identifier,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeAllPlaylists() {
|
||||
const filter = { kinds: [PLAYLIST_KIND], '#t': ['music'], limit: 600 };
|
||||
const filter = { kinds: [PLAYLIST_KIND], limit: 600 };
|
||||
if (isExternalTargetMode()) {
|
||||
filter.authors = [targetPubkey];
|
||||
} else if (!friendsFeedEnabled && currentPubkey) {
|
||||
filter.authors = [currentPubkey];
|
||||
}
|
||||
subscribe(
|
||||
|
||||
try {
|
||||
playlistSubscription?.close?.();
|
||||
} catch {
|
||||
// ignore stale subscription handle errors
|
||||
}
|
||||
|
||||
playlistSubscription = subscribe(
|
||||
filter,
|
||||
{ closeOnEose: false, cacheUsage: 'PARALLEL' }
|
||||
);
|
||||
@@ -3301,6 +3581,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
if (!evt || typeof evt !== 'object') return;
|
||||
if (evt.kind !== PLAYLIST_KIND || !isMusicPlaylistEvent(evt)) return;
|
||||
if (isExternalTargetMode() && evt.pubkey !== targetPubkey) return;
|
||||
if (!friendsFeedEnabled && !isExternalTargetMode() && evt.pubkey !== currentPubkey) return;
|
||||
|
||||
const treatAsMine = Boolean(
|
||||
currentPubkey
|
||||
@@ -4347,14 +4628,14 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
title: track.title || '',
|
||||
});
|
||||
|
||||
const qualityCandidates = ['HIGH', 'MP3_320', 'LOSSLESS'];
|
||||
const qualityCandidates = ['DIRECT'];
|
||||
let lastError = null;
|
||||
|
||||
for (const quality of qualityCandidates) {
|
||||
try {
|
||||
console.debug('[music][download] requesting stream', { trackId: track.id, quality });
|
||||
|
||||
const stream = await musicApi.getTrackStream(track.id, quality);
|
||||
const stream = await musicApi.getTrackStream(track.id);
|
||||
const streamUrl = stream?.streamUrl;
|
||||
if (!streamUrl) {
|
||||
lastError = new Error(`No stream URL for quality ${quality}`);
|
||||
@@ -4583,6 +4864,7 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
installImageFallbacks();
|
||||
|
||||
initMusicResizableColumns();
|
||||
updateUploadUiState();
|
||||
|
||||
musicPlayer.onTrackChanged = (track) => {
|
||||
updateMusicPlayerMeta(track);
|
||||
@@ -4606,6 +4888,10 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
applyTargetPubkeyFromInput();
|
||||
});
|
||||
|
||||
musicEls.uploadBtn?.addEventListener('click', async () => {
|
||||
await handleGruuvUploadClick();
|
||||
});
|
||||
|
||||
musicEls.results.addEventListener('click', async (event) => {
|
||||
|
||||
const playAlbumNow = event.target.closest('[data-play-album-now]');
|
||||
@@ -4832,6 +5118,15 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
navigate(`/playlist/${encodeURIComponent(pubkey)}/${encodeURIComponent(identifier)}`);
|
||||
});
|
||||
|
||||
musicEls.loadFriendsBtn?.addEventListener('click', () => {
|
||||
if (isExternalTargetMode()) return;
|
||||
if (friendsFeedEnabled) return;
|
||||
friendsFeedEnabled = true;
|
||||
setMusicStatus('Loading friend playlists…', 'neutral');
|
||||
renderFriendPlaylists();
|
||||
subscribeAllPlaylists();
|
||||
});
|
||||
|
||||
musicEls.playlistNameInput?.addEventListener('keydown', async (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
@@ -5125,11 +5420,16 @@ import { GreyscaleAPI } from './js/greyscale-api.mjs';
|
||||
}
|
||||
loadQueueLocal();
|
||||
updateViewerControls();
|
||||
friendsFeedEnabled = isExternalTargetMode();
|
||||
if (musicEls.loadFriendsBtn) {
|
||||
musicEls.loadFriendsBtn.disabled = isExternalTargetMode();
|
||||
musicEls.loadFriendsBtn.textContent = isExternalTargetMode() ? 'Auto' : 'Load';
|
||||
}
|
||||
renderMyPlaylists();
|
||||
renderFriendPlaylists();
|
||||
renderQueue();
|
||||
subscribeAllPlaylists();
|
||||
window.addEventListener('ndkEvent', handleMusicNdkEvent);
|
||||
subscribeAllPlaylists();
|
||||
|
||||
if (!window.location.hash) {
|
||||
const episodeRoute = getEpisodeRouteFromUrlContext();
|
||||
|
||||
1911
www/ndk-worker.js
1911
www/ndk-worker.js
File diff suppressed because it is too large
Load Diff
4401
www/nostr-lite.js
4401
www/nostr-lite.js
File diff suppressed because it is too large
Load Diff
@@ -436,7 +436,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
@@ -393,6 +393,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
|
||||
const NOTIFICATION_KINDS = [1, 3, 4, 6, 7, 9735];
|
||||
const NOTIFICATIONS_PAGE_SIZE = 40;
|
||||
const MAX_STORED_NOTIFICATIONS = 500;
|
||||
const MAX_POST_IMAGE_CACHE = 200;
|
||||
const SVG_UNCHECKED = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><rect x="2" y="2" width="6" height="6" /></svg>`;
|
||||
const SVG_CHECKED = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
|
||||
const NOTIFICATION_FILTER_DEFS = [
|
||||
@@ -883,6 +885,173 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
}
|
||||
}
|
||||
|
||||
function buildNotificationRow(item) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'notif-item';
|
||||
row.dataset.notifId = item.id;
|
||||
row.dataset.notifCreatedAt = String(item.created_at || 0);
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'notif-avatar';
|
||||
avatar.alt = '';
|
||||
avatar.src = item.actor.picture || './favicon/favicon-dots2.ico';
|
||||
avatar.onerror = () => {
|
||||
console.warn('[notifications] actor avatar failed to load', {
|
||||
eventId: item.id,
|
||||
actor: item.actor.name,
|
||||
src: avatar.src
|
||||
});
|
||||
avatar.onerror = null;
|
||||
avatar.src = './favicon/favicon-dots2.ico';
|
||||
};
|
||||
|
||||
if (item.actor?.pubkey) {
|
||||
avatar.style.cursor = 'pointer';
|
||||
avatar.title = 'Open profile';
|
||||
avatar.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`;
|
||||
window.open(profileUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
}
|
||||
|
||||
const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || '');
|
||||
const secondaryEventUrl = makeEventPermalink(item.targetEventId);
|
||||
|
||||
let secondaryNode;
|
||||
|
||||
if (secondaryImageUrl) {
|
||||
const secondaryAvatar = document.createElement('img');
|
||||
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
|
||||
secondaryAvatar.alt = '';
|
||||
secondaryAvatar.src = secondaryImageUrl;
|
||||
secondaryAvatar.onerror = () => {
|
||||
console.debug('[notifications] secondary image failed to load', {
|
||||
eventId: item.id,
|
||||
src: secondaryAvatar.src
|
||||
});
|
||||
secondaryAvatar.style.visibility = 'hidden';
|
||||
};
|
||||
|
||||
if (secondaryEventUrl) {
|
||||
secondaryAvatar.style.cursor = 'pointer';
|
||||
secondaryAvatar.title = 'Open related event';
|
||||
secondaryAvatar.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
}
|
||||
|
||||
secondaryNode = secondaryAvatar;
|
||||
} else if (secondaryEventUrl) {
|
||||
const secondaryPlaceholder = document.createElement('a');
|
||||
secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder';
|
||||
secondaryPlaceholder.href = secondaryEventUrl;
|
||||
secondaryPlaceholder.target = '_blank';
|
||||
secondaryPlaceholder.rel = 'noopener noreferrer';
|
||||
secondaryPlaceholder.title = 'Open related event';
|
||||
secondaryPlaceholder.setAttribute('aria-label', 'Open related event');
|
||||
secondaryPlaceholder.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
secondaryNode = secondaryPlaceholder;
|
||||
} else {
|
||||
const secondaryAvatar = document.createElement('div');
|
||||
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
|
||||
secondaryAvatar.style.visibility = 'hidden';
|
||||
secondaryNode = secondaryAvatar;
|
||||
}
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'notif-content';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'notif-name';
|
||||
name.textContent = item.actor.name;
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'notif-desc';
|
||||
desc.textContent = item.description;
|
||||
|
||||
content.appendChild(name);
|
||||
content.appendChild(desc);
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'notif-time';
|
||||
time.textContent = formatTimeAgo(item.created_at || 0);
|
||||
timeElements.set(time, item.created_at || 0);
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'notif-right';
|
||||
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.className = 'notif-menu-btn';
|
||||
menuButton.type = 'button';
|
||||
menuButton.textContent = '⋯';
|
||||
menuButton.title = 'Notification menu';
|
||||
|
||||
const menuPanel = document.createElement('div');
|
||||
menuPanel.className = 'notif-menu-panel';
|
||||
menuPanel.style.position = 'absolute';
|
||||
menuPanel.style.right = '0';
|
||||
menuPanel.style.top = '26px';
|
||||
menuPanel.style.zIndex = '10';
|
||||
menuPanel.style.display = 'none';
|
||||
menuPanel.style.minWidth = '140px';
|
||||
menuPanel.style.padding = '6px';
|
||||
menuPanel.style.border = 'var(--border)';
|
||||
menuPanel.style.borderRadius = 'var(--border-radius)';
|
||||
menuPanel.style.background = 'var(--secondary-color)';
|
||||
|
||||
const blockButton = document.createElement('button');
|
||||
blockButton.type = 'button';
|
||||
blockButton.textContent = 'Block account';
|
||||
blockButton.style.width = '100%';
|
||||
blockButton.style.textAlign = 'left';
|
||||
blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)';
|
||||
blockButton.style.borderRadius = 'var(--button-border-radius)';
|
||||
blockButton.style.background = 'var(--button-background-color)';
|
||||
blockButton.style.color = 'var(--button-color)';
|
||||
blockButton.style.padding = '6px 8px';
|
||||
blockButton.style.cursor = 'pointer';
|
||||
|
||||
blockButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const actorPubkey = String(item?.actor?.pubkey || '').trim();
|
||||
if (!actorPubkey) return;
|
||||
|
||||
const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await blockActorPubkey(actorPubkey);
|
||||
} catch (error) {
|
||||
console.warn('[notifications] Failed to block account:', error?.message || error);
|
||||
} finally {
|
||||
menuPanel.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
menuPanel.appendChild(blockButton);
|
||||
|
||||
right.style.position = 'relative';
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
right.appendChild(menuButton);
|
||||
right.appendChild(menuPanel);
|
||||
right.appendChild(time);
|
||||
|
||||
row.appendChild(avatar);
|
||||
row.appendChild(secondaryNode);
|
||||
row.appendChild(content);
|
||||
row.appendChild(right);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
const items = Array.from(notificationsById.values())
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
@@ -892,172 +1061,7 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
timeElements.clear();
|
||||
|
||||
for (const item of visibleItems) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'notif-item';
|
||||
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'notif-avatar';
|
||||
avatar.alt = '';
|
||||
avatar.src = item.actor.picture || './favicon/favicon-dots2.ico';
|
||||
avatar.onerror = () => {
|
||||
console.warn('[notifications] actor avatar failed to load', {
|
||||
eventId: item.id,
|
||||
actor: item.actor.name,
|
||||
src: avatar.src
|
||||
});
|
||||
avatar.onerror = null;
|
||||
avatar.src = './favicon/favicon-dots2.ico';
|
||||
};
|
||||
|
||||
if (item.actor?.pubkey) {
|
||||
avatar.style.cursor = 'pointer';
|
||||
avatar.title = 'Open profile';
|
||||
avatar.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`;
|
||||
window.open(profileUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
}
|
||||
|
||||
const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || '');
|
||||
const secondaryEventUrl = makeEventPermalink(item.targetEventId);
|
||||
|
||||
let secondaryNode;
|
||||
|
||||
if (secondaryImageUrl) {
|
||||
const secondaryAvatar = document.createElement('img');
|
||||
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
|
||||
secondaryAvatar.alt = '';
|
||||
secondaryAvatar.src = secondaryImageUrl;
|
||||
secondaryAvatar.onerror = () => {
|
||||
console.debug('[notifications] secondary image failed to load', {
|
||||
eventId: item.id,
|
||||
src: secondaryAvatar.src
|
||||
});
|
||||
secondaryAvatar.style.visibility = 'hidden';
|
||||
};
|
||||
|
||||
if (secondaryEventUrl) {
|
||||
secondaryAvatar.style.cursor = 'pointer';
|
||||
secondaryAvatar.title = 'Open related event';
|
||||
secondaryAvatar.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
}
|
||||
|
||||
secondaryNode = secondaryAvatar;
|
||||
} else if (secondaryEventUrl) {
|
||||
const secondaryPlaceholder = document.createElement('a');
|
||||
secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder';
|
||||
secondaryPlaceholder.href = secondaryEventUrl;
|
||||
secondaryPlaceholder.target = '_blank';
|
||||
secondaryPlaceholder.rel = 'noopener noreferrer';
|
||||
secondaryPlaceholder.title = 'Open related event';
|
||||
secondaryPlaceholder.setAttribute('aria-label', 'Open related event');
|
||||
secondaryPlaceholder.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
secondaryNode = secondaryPlaceholder;
|
||||
} else {
|
||||
const secondaryAvatar = document.createElement('div');
|
||||
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
|
||||
secondaryAvatar.style.visibility = 'hidden';
|
||||
secondaryNode = secondaryAvatar;
|
||||
}
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'notif-content';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'notif-name';
|
||||
name.textContent = item.actor.name;
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'notif-desc';
|
||||
desc.textContent = item.description;
|
||||
|
||||
content.appendChild(name);
|
||||
content.appendChild(desc);
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'notif-time';
|
||||
time.textContent = formatTimeAgo(item.created_at || 0);
|
||||
timeElements.set(time, item.created_at || 0);
|
||||
|
||||
const right = document.createElement('div');
|
||||
right.className = 'notif-right';
|
||||
|
||||
const menuButton = document.createElement('button');
|
||||
menuButton.className = 'notif-menu-btn';
|
||||
menuButton.type = 'button';
|
||||
menuButton.textContent = '⋯';
|
||||
menuButton.title = 'Notification menu';
|
||||
|
||||
const menuPanel = document.createElement('div');
|
||||
menuPanel.style.position = 'absolute';
|
||||
menuPanel.style.right = '0';
|
||||
menuPanel.style.top = '26px';
|
||||
menuPanel.style.zIndex = '10';
|
||||
menuPanel.style.display = 'none';
|
||||
menuPanel.style.minWidth = '140px';
|
||||
menuPanel.style.padding = '6px';
|
||||
menuPanel.style.border = 'var(--border)';
|
||||
menuPanel.style.borderRadius = 'var(--border-radius)';
|
||||
menuPanel.style.background = 'var(--secondary-color)';
|
||||
|
||||
const blockButton = document.createElement('button');
|
||||
blockButton.type = 'button';
|
||||
blockButton.textContent = 'Block account';
|
||||
blockButton.style.width = '100%';
|
||||
blockButton.style.textAlign = 'left';
|
||||
blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)';
|
||||
blockButton.style.borderRadius = 'var(--button-border-radius)';
|
||||
blockButton.style.background = 'var(--button-background-color)';
|
||||
blockButton.style.color = 'var(--button-color)';
|
||||
blockButton.style.padding = '6px 8px';
|
||||
blockButton.style.cursor = 'pointer';
|
||||
|
||||
blockButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const actorPubkey = String(item?.actor?.pubkey || '').trim();
|
||||
if (!actorPubkey) return;
|
||||
|
||||
const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await blockActorPubkey(actorPubkey);
|
||||
} catch (error) {
|
||||
console.warn('[notifications] Failed to block account:', error?.message || error);
|
||||
} finally {
|
||||
menuPanel.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
menuPanel.appendChild(blockButton);
|
||||
|
||||
right.style.position = 'relative';
|
||||
menuButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (!right.contains(event.target)) {
|
||||
menuPanel.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
right.appendChild(menuButton);
|
||||
right.appendChild(menuPanel);
|
||||
right.appendChild(time);
|
||||
|
||||
row.appendChild(avatar);
|
||||
row.appendChild(secondaryNode);
|
||||
row.appendChild(content);
|
||||
row.appendChild(right);
|
||||
|
||||
const row = buildNotificationRow(item);
|
||||
divNotifications.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -1071,6 +1075,56 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
renderNotificationFilters();
|
||||
}
|
||||
|
||||
function prependNotificationRow(item) {
|
||||
if (divNotifications.querySelector(`[data-notif-id="${CSS.escape(String(item.id))}"]`)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (divNotifications.children.length === 0) {
|
||||
renderNotifications();
|
||||
return false;
|
||||
}
|
||||
|
||||
const row = buildNotificationRow(item);
|
||||
const newCreatedAt = Number(item.created_at || 0);
|
||||
|
||||
let inserted = false;
|
||||
for (const existing of divNotifications.children) {
|
||||
const existingCreatedAt = Number(existing.dataset?.notifCreatedAt || 0);
|
||||
if (newCreatedAt > existingCreatedAt) {
|
||||
divNotifications.insertBefore(row, existing);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
divNotifications.appendChild(row);
|
||||
}
|
||||
|
||||
while (divNotifications.children.length > visibleNotificationsCount) {
|
||||
const last = divNotifications.lastElementChild;
|
||||
if (last) {
|
||||
const timeEl = last.querySelector('.notif-time');
|
||||
if (timeEl) timeElements.delete(timeEl);
|
||||
last.remove();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const total = notificationsById.size;
|
||||
const hasMore = total > visibleNotificationsCount;
|
||||
if (btnViewMoreNotifications) {
|
||||
btnViewMoreNotifications.style.display = hasMore ? 'block' : 'none';
|
||||
}
|
||||
|
||||
updateHint();
|
||||
refreshTimes();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function blockActorPubkey(pubkey) {
|
||||
const target = String(pubkey || '').trim();
|
||||
if (!target || target === currentPubkey) return;
|
||||
@@ -1092,6 +1146,29 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
function pruneNotificationMaps() {
|
||||
if (notificationsById.size > MAX_STORED_NOTIFICATIONS) {
|
||||
const sorted = Array.from(notificationsById.values())
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
const keep = new Set(sorted.slice(0, MAX_STORED_NOTIFICATIONS).map((n) => n.id));
|
||||
for (const id of Array.from(notificationsById.keys())) {
|
||||
if (!keep.has(id)) {
|
||||
notificationsById.delete(id);
|
||||
unreadNotificationsById.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (postImageCache.size > MAX_POST_IMAGE_CACHE) {
|
||||
const excess = postImageCache.size - MAX_POST_IMAGE_CACHE;
|
||||
let removed = 0;
|
||||
for (const key of Array.from(postImageCache.keys())) {
|
||||
if (removed >= excess) break;
|
||||
postImageCache.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addNotificationFromEvent(evt) {
|
||||
if (!evt || !evt.id || !evt.pubkey) return;
|
||||
if (evt.pubkey === currentPubkey) return;
|
||||
@@ -1138,7 +1215,15 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
isUnread: createdAt > notificationsReadAt
|
||||
});
|
||||
|
||||
renderNotifications();
|
||||
if (divNotifications.children.length > 0) {
|
||||
prependNotificationRow(item);
|
||||
} else {
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
if (notificationsById.size > MAX_STORED_NOTIFICATIONS + 100) {
|
||||
pruneNotificationMaps();
|
||||
}
|
||||
}
|
||||
|
||||
function eventTargetsCurrentPubkey(evt) {
|
||||
@@ -1162,6 +1247,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
await addNotificationFromEvent(evt);
|
||||
}
|
||||
|
||||
pruneNotificationMaps();
|
||||
|
||||
console.log('[notifications] cache preload complete', {
|
||||
cachedCount: Array.isArray(cached) ? cached.length : 0,
|
||||
loadedCount: filtered.length
|
||||
@@ -1184,6 +1271,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
await addNotificationFromEvent(evt);
|
||||
}
|
||||
|
||||
pruneNotificationMaps();
|
||||
|
||||
console.log('[notifications] relay hydration complete', {
|
||||
relayCount: sorted.length
|
||||
});
|
||||
@@ -1297,6 +1386,19 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
(async function main() {
|
||||
try {
|
||||
initHamburgerMenu();
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest('.notif-right')) return;
|
||||
const panels = document.querySelectorAll('.notif-menu-panel');
|
||||
for (const panel of panels) {
|
||||
if (panel.style.display !== 'none') {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
syncNotificationFiltersFromSettings({});
|
||||
|
||||
const divSvgHam = document.getElementById('divSvgHam');
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script type="text/javascript" src="./js/qrcode-svg.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./ndk-core.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
<script type="text/javascript" src="./js/qrcode-generator.min.js"></script>
|
||||
|
||||
<script type="module">
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -301,7 +301,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getRelayData, fetchEventsFromAllRelays, ndkFetchEvents, queryCache } from './js/init-ndk.mjs';
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -1015,7 +1015,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -325,7 +325,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -323,7 +323,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
1278
www/post-feed.html
Normal file
1278
www/post-feed.html
Normal file
File diff suppressed because it is too large
Load Diff
118
www/post.html
118
www/post.html
@@ -158,7 +158,7 @@
|
||||
<div id="divFooter">
|
||||
<div id="divFooterLeft" class="divFooterBox"></div>
|
||||
<div id="divFooterCenter" class="divFooterBox">
|
||||
<button id="commentsToggleButton">Comments</button>
|
||||
<span id="spanBroadcastStatus"></span>
|
||||
</div>
|
||||
<div id="divFooterRight" class="divFooterBox"></div>
|
||||
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
|
||||
@@ -202,7 +202,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
@@ -240,6 +240,7 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const MIN_BOOTSTRAP_POSTS = 10;
|
||||
const BOOTSTRAP_WINDOWS_SECONDS = [24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 90 * 24 * 3600];
|
||||
const BOOTSTRAP_PER_WINDOW_LIMIT = 120;
|
||||
const MAX_STORED_POSTS = 500;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const profileParamRaw = (urlParams.get('profile') || '').trim();
|
||||
@@ -612,11 +613,24 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
return eTags.every(t => t[3] === 'mention');
|
||||
}
|
||||
|
||||
function prunePostsArray() {
|
||||
posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
if (posts.length > MAX_STORED_POSTS) {
|
||||
posts.length = MAX_STORED_POSTS;
|
||||
postIds.clear();
|
||||
posts.forEach((p) => postIds.add(p.id));
|
||||
for (const id of Array.from(renderedPostIds)) {
|
||||
if (!postIds.has(id)) renderedPostIds.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function upsertPost(evt) {
|
||||
if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false;
|
||||
if (isEventMuted(evt)) return false;
|
||||
postIds.add(evt.id);
|
||||
posts.push(evt);
|
||||
if (!isEventMode && posts.length > MAX_STORED_POSTS + 50) prunePostsArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -652,6 +666,68 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
divFooterRight.textContent = `${posts.length} posts`;
|
||||
}
|
||||
|
||||
function prependPostCard(post) {
|
||||
if (!post?.id || renderedPostIds.has(post.id)) return false;
|
||||
|
||||
// Event mode should never prepend — it uses renderSingleEvent / renderFeed.
|
||||
if (isEventMode) {
|
||||
renderFeed();
|
||||
return false;
|
||||
}
|
||||
|
||||
// If feed is empty or not yet rendered, fall back to full rebuild.
|
||||
if (divFeed.children.length === 0 || renderedPostIds.size === 0) {
|
||||
renderFeed();
|
||||
return false;
|
||||
}
|
||||
|
||||
const postCreatedAt = post.created_at || 0;
|
||||
|
||||
// Find the correct insertion point by scanning visible children's created_at.
|
||||
// Posts are displayed newest-first, so we insert before the first child
|
||||
// that is older than (or equal to) the new post.
|
||||
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
|
||||
let inserted = false;
|
||||
|
||||
for (const child of divFeed.children) {
|
||||
const childId = child.getAttribute('data-post-id');
|
||||
if (!childId) continue;
|
||||
const childPost = posts.find((p) => p.id === childId);
|
||||
if (!childPost) continue;
|
||||
const childCreatedAt = childPost.created_at || 0;
|
||||
if (postCreatedAt >= childCreatedAt) {
|
||||
divFeed.insertBefore(postEl, child);
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
// Post is older than all visible posts — append at the bottom.
|
||||
divFeed.appendChild(postEl);
|
||||
}
|
||||
|
||||
renderedPostIds.add(post.id);
|
||||
cards.wireInteractions([post.id], { currentPubkey });
|
||||
|
||||
// Trim DOM to displayCount.
|
||||
while (divFeed.children.length > displayCount) {
|
||||
const lastChild = divFeed.lastElementChild;
|
||||
if (!lastChild) break;
|
||||
const removedId = lastChild.getAttribute('data-post-id');
|
||||
if (removedId) renderedPostIds.delete(removedId);
|
||||
divFeed.removeChild(lastChild);
|
||||
}
|
||||
|
||||
btnSeeMore.style.display = posts.length > displayCount ? 'block' : 'none';
|
||||
divHint.textContent = posts.length > 0
|
||||
? `${posts.length} posts`
|
||||
: (isProfileMode ? 'No posts yet.' : 'No posts yet — write one above.');
|
||||
divFooterRight.textContent = `${posts.length} posts`;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderSingleEvent(evt) {
|
||||
if (!evt?.id) return;
|
||||
posts.length = 0;
|
||||
@@ -810,18 +886,38 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
|
||||
document.getElementById('logoutButton')?.addEventListener('click', logout);
|
||||
|
||||
document.getElementById('commentsToggleButton')?.addEventListener('click', () => {
|
||||
const nextVisible = !cards.getCommentsVisible();
|
||||
cards.setCommentsVisible(nextVisible);
|
||||
document.getElementById('commentsToggleButton')?.classList.toggle('dimmed', !nextVisible);
|
||||
});
|
||||
|
||||
btnSeeMore.addEventListener('click', () => {
|
||||
displayCount += SEE_MORE_INCREMENT;
|
||||
renderFeed();
|
||||
});
|
||||
|
||||
await initNDKPage();
|
||||
|
||||
// Live broadcast progress display in the footer center section.
|
||||
// The worker streams per-relay publish results via this window event
|
||||
// (see plans/broadcast-relays-feature.md §8).
|
||||
const spanBroadcastStatus = document.getElementById('spanBroadcastStatus');
|
||||
window.addEventListener('ndkBroadcastProgress', (event) => {
|
||||
const d = event.detail;
|
||||
if (!d || !spanBroadcastStatus) return;
|
||||
|
||||
if (d.phase === 'start') {
|
||||
spanBroadcastStatus.textContent = `Broadcasting to ${d.total} relays…`;
|
||||
} else if (d.phase === 'progress') {
|
||||
const shortRelay = d.latestRelay
|
||||
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
|
||||
: '';
|
||||
const batchInfo = (d.batchTotal && d.batchTotal > 0)
|
||||
? ` · ${d.batchCurrent}/${d.batchTotal} batches`
|
||||
: '';
|
||||
const relayInfo = shortRelay ? ` · ${shortRelay}` : '';
|
||||
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays${batchInfo}${relayInfo}`;
|
||||
} else if (d.phase === 'done') {
|
||||
// Keep the result displayed until the next publish overwrites it.
|
||||
spanBroadcastStatus.textContent = `✅ ${d.successful}/${d.total} relays` + (d.failed > 0 ? ` (${d.failed} failed)` : '');
|
||||
}
|
||||
});
|
||||
|
||||
currentPubkey = await getPubkey();
|
||||
await injectHeaderAvatar(currentPubkey);
|
||||
|
||||
@@ -889,7 +985,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
}
|
||||
|
||||
if (!upsertPost(evt)) return;
|
||||
renderFeed();
|
||||
if (renderedPostIds.size > 0 && divFeed.children.length > 0) {
|
||||
prependPostCard(evt);
|
||||
} else {
|
||||
renderFeed();
|
||||
}
|
||||
});
|
||||
|
||||
if (isEventMode) {
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
|
||||
|
||||
1399
www/projects.html
Normal file
1399
www/projects.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -514,7 +514,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
390
www/relays.html
390
www/relays.html
@@ -7,6 +7,7 @@
|
||||
<title>RELAYS</title>
|
||||
|
||||
<link rel="stylesheet" href="./css/client.css" />
|
||||
<link rel="stylesheet" href="./css/sidenav-sections.css" />
|
||||
|
||||
<!-- Initialize theme BEFORE any components load -->
|
||||
<script>
|
||||
@@ -256,6 +257,10 @@
|
||||
================================================================ -->
|
||||
<div id="divBody" class="clsBodyFull">
|
||||
<div id="divRelays">Loading relays...</div>
|
||||
<div id="divDiscoveredRelaysWrap" style="width:100%;margin:10px 0 0 0;overflow-x:auto;">
|
||||
<div id="divDiscoveredRelaysTitle" style="font-size:120%;margin-bottom:6px;cursor:pointer;user-select:none;">▸ Discovered Relays (Outbox)</div>
|
||||
<div id="divDiscoveredRelays" style="display:none;max-height:300px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div id="divRelayEventsWrap">
|
||||
<div id="divRelayEvents" class="relay-events-stream"></div>
|
||||
</div>
|
||||
@@ -326,19 +331,30 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
IMPORTS
|
||||
================================================================ */
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents } from './js/init-ndk.mjs';
|
||||
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect, getRelayData, getRelayStats, reconnectRelay, publishEvent, getVersion, updateVersionDisplay, ndkFetchEvents, setRelayEventLogging, getDiscoveredRelays } from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
|
||||
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
|
||||
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
|
||||
import {
|
||||
loadBroadcastRelays,
|
||||
addBroadcastRelay,
|
||||
removeBroadcastRelay,
|
||||
seedFromDiscovered,
|
||||
unskipRelay,
|
||||
onBroadcastRelaysChanged,
|
||||
getActiveBroadcastRelays,
|
||||
getSkippedBroadcastRelays,
|
||||
} from './js/broadcast-relays.mjs';
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
console.log(`[relays.html ${VERSION}] Loading...`);
|
||||
@@ -370,6 +386,7 @@ const versionInfo = await getVersion();
|
||||
let selectedRelayUrl = null; // Relay row currently selected for debug event history
|
||||
const relayEventHistory = new Map(); // relay.url -> [{ timestamp, rawTimestamp, eventType, side, summary }]
|
||||
const relayUiStats = new Map(); // relay.url -> { readCount, writeCount }
|
||||
let connectionHistoryEnabled = false; // Toggled via sidenav; gates live relay event logging
|
||||
|
||||
const RELAY_EVENTS_DB_NAME = 'relay-events';
|
||||
const RELAY_EVENTS_DB_VERSION = 1;
|
||||
@@ -417,8 +434,80 @@ const versionInfo = await getVersion();
|
||||
if (hamburgerInstance) {
|
||||
hamburgerInstance.animateTo('arrow_left');
|
||||
}
|
||||
divSideNavBody.innerHTML = "Relay management options coming soon...";
|
||||
|
||||
const historyEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
|
||||
connectionHistoryEnabled = historyEnabled;
|
||||
divSideNavBody.innerHTML = `
|
||||
<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>
|
||||
<div id="divHistoryToggleCheckbox" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">${historyEnabled ? SVG_CHECKED : SVG_UNCHECKED}</div>
|
||||
</div>
|
||||
<div id="divBroadcastRelaysSettings" style="padding:4px 10px;font-size:80%;color:var(--primary-color);">
|
||||
<div id="divBroadcastRelaysHeader" style="display:flex;align-items:center;cursor:pointer;">
|
||||
<span style="flex:1;">Broadcast Relays
|
||||
(<span id="spanBroadcastActive">0</span> active,
|
||||
<span id="spanBroadcastSkipped">0</span> skipped)
|
||||
</span>
|
||||
<span id="divBroadcastToggleIcon" class="divSvg" style="width:16px;height:16px;flex-shrink:0;">▸</span>
|
||||
</div>
|
||||
<div id="divBroadcastRelaysContent" style="display:none;margin-top:6px;">
|
||||
<div style="display:flex;gap:4px;margin-bottom:6px;">
|
||||
<input id="txtBroadcastAdd" placeholder="wss://relay.example"
|
||||
style="flex:1;font-size:80%;padding:2px 4px;" />
|
||||
<button id="btnBroadcastAdd" style="font-size:80%;">Add</button>
|
||||
</div>
|
||||
<button id="btnBroadcastSeed" style="font-size:80%;width:100%;margin-bottom:6px;">
|
||||
Add all discovered relays
|
||||
</button>
|
||||
<div id="divBroadcastList" style="max-height:200px;overflow-y:auto;"></div>
|
||||
<div id="divBroadcastSkippedList" style="margin-top:8px;opacity:0.6;max-height:150px;overflow-y:auto;">
|
||||
<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>
|
||||
</div>
|
||||
<button id="btnBroadcastSave" style="font-size:80%;width:100%;margin-top:6px;">
|
||||
Save to Nostr (kind 10088)
|
||||
</button>
|
||||
<div id="divBroadcastStatus" style="font-size:75%;margin-top:4px;min-height:1em;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up the connection history toggle
|
||||
const divHistoryToggle = document.getElementById('divHistoryToggleCheckbox');
|
||||
if (divHistoryToggle) {
|
||||
const toggleHistory = () => {
|
||||
const checked = !connectionHistoryEnabled;
|
||||
connectionHistoryEnabled = checked;
|
||||
localStorage.setItem('relayConnectionHistory', checked);
|
||||
setRelayEventLogging(checked);
|
||||
divHistoryToggle.innerHTML = checked ? SVG_CHECKED : SVG_UNCHECKED;
|
||||
const wrap = document.getElementById('divRelayEventsWrap');
|
||||
if (wrap) {
|
||||
wrap.style.display = checked ? '' : 'none';
|
||||
}
|
||||
if (checked) {
|
||||
if (selectedRelayUrl) {
|
||||
loadRelayEventsFromDb(selectedRelayUrl);
|
||||
}
|
||||
renderRelayEventsForSelectedRelay();
|
||||
} else {
|
||||
if (divRelayEvents) {
|
||||
divRelayEvents.innerHTML = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
divHistoryToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleHistory();
|
||||
});
|
||||
document.getElementById('divRelaySettings').addEventListener('click', toggleHistory);
|
||||
}
|
||||
|
||||
// Wire up the Broadcast Relays collapsible section.
|
||||
initBroadcastRelaysSection();
|
||||
|
||||
// Refresh broadcast relay data from the worker every time the sidenav
|
||||
// opens so the user sees the freshest active/skipped lists.
|
||||
loadBroadcastRelays();
|
||||
|
||||
// Initialize version bar buttons when sidenav opens (lazy load)
|
||||
if (!logoutHamburger) {
|
||||
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
|
||||
@@ -463,6 +552,159 @@ const versionInfo = await getVersion();
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
BROADCAST RELAYS SECTION (kind 10088)
|
||||
================================================================ */
|
||||
let broadcastRelaysSectionWired = false;
|
||||
|
||||
function initBroadcastRelaysSection() {
|
||||
// Toggle header click → show/hide content.
|
||||
const header = document.getElementById('divBroadcastRelaysHeader');
|
||||
const content = document.getElementById('divBroadcastRelaysContent');
|
||||
const icon = document.getElementById('divBroadcastToggleIcon');
|
||||
if (header && content) {
|
||||
header.addEventListener('click', () => {
|
||||
const isOpen = content.style.display !== 'none';
|
||||
content.style.display = isOpen ? 'none' : 'block';
|
||||
if (icon) icon.textContent = isOpen ? '▸' : '▾';
|
||||
});
|
||||
}
|
||||
|
||||
// Add button.
|
||||
const btnAdd = document.getElementById('btnBroadcastAdd');
|
||||
const txtAdd = document.getElementById('txtBroadcastAdd');
|
||||
if (btnAdd && txtAdd) {
|
||||
const doAdd = async () => {
|
||||
const url = txtAdd.value.trim();
|
||||
if (!url) return;
|
||||
txtAdd.value = '';
|
||||
setBroadcastStatus('Adding…');
|
||||
await addBroadcastRelay(url);
|
||||
setBroadcastStatus('Added');
|
||||
setTimeout(() => setBroadcastStatus(''), 2000);
|
||||
};
|
||||
btnAdd.addEventListener('click', doAdd);
|
||||
txtAdd.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); doAdd(); }
|
||||
});
|
||||
}
|
||||
|
||||
// Seed from discovered relays button.
|
||||
const btnSeed = document.getElementById('btnBroadcastSeed');
|
||||
if (btnSeed) {
|
||||
btnSeed.addEventListener('click', async () => {
|
||||
setBroadcastStatus('Seeding from discovered relays…');
|
||||
try {
|
||||
const added = await seedFromDiscovered();
|
||||
setBroadcastStatus(added > 0
|
||||
? `Added ${added} relay${added === 1 ? '' : 's'}`
|
||||
: 'No new relays to add');
|
||||
} catch (err) {
|
||||
setBroadcastStatus('Seed failed: ' + (err?.message || err));
|
||||
}
|
||||
setTimeout(() => setBroadcastStatus(''), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// Save button (the worker auto-saves on add/remove, but this gives the
|
||||
// user an explicit "flush to Nostr" action as the plan specifies).
|
||||
const btnSave = document.getElementById('btnBroadcastSave');
|
||||
if (btnSave) {
|
||||
btnSave.addEventListener('click', async () => {
|
||||
setBroadcastStatus('Saving to Nostr (kind 10088)…');
|
||||
try {
|
||||
// Re-save the current active list to force a fresh kind 10088
|
||||
// publish (the worker deduplicates/merges with existing r-tags).
|
||||
const { saveBroadcastRelays } = await import('./js/init-ndk.mjs');
|
||||
await saveBroadcastRelays(getActiveBroadcastRelays());
|
||||
setBroadcastStatus('Saved');
|
||||
} catch (err) {
|
||||
setBroadcastStatus('Save failed: ' + (err?.message || err));
|
||||
}
|
||||
setTimeout(() => setBroadcastStatus(''), 3000);
|
||||
});
|
||||
}
|
||||
|
||||
// Subscribe to changes from the wrapper module (re-render).
|
||||
if (!broadcastRelaysSectionWired) {
|
||||
broadcastRelaysSectionWired = true;
|
||||
onBroadcastRelaysChanged(() => renderBroadcastRelaysSection());
|
||||
|
||||
// Cross-tab / cross-client sync: the worker forwards kind 10088
|
||||
// subscription updates as this window event.
|
||||
window.addEventListener('ndkBroadcastRelaysUpdated', () => {
|
||||
loadBroadcastRelays();
|
||||
});
|
||||
}
|
||||
|
||||
renderBroadcastRelaysSection();
|
||||
}
|
||||
|
||||
function setBroadcastStatus(text) {
|
||||
const el = document.getElementById('divBroadcastStatus');
|
||||
if (el) el.textContent = text || '';
|
||||
}
|
||||
|
||||
function renderBroadcastRelaysSection() {
|
||||
const spanActive = document.getElementById('spanBroadcastActive');
|
||||
const spanSkipped = document.getElementById('spanBroadcastSkipped');
|
||||
const listEl = document.getElementById('divBroadcastList');
|
||||
const skippedEl = document.getElementById('divBroadcastSkippedList');
|
||||
if (!listEl || !skippedEl) return;
|
||||
|
||||
const activeUrls = getActiveBroadcastRelays();
|
||||
const skipped = getSkippedBroadcastRelays();
|
||||
|
||||
if (spanActive) spanActive.textContent = String(activeUrls.length);
|
||||
if (spanSkipped) spanSkipped.textContent = String(skipped.length);
|
||||
|
||||
// Active list
|
||||
if (activeUrls.length === 0) {
|
||||
listEl.innerHTML = '<div style="font-size:75%;opacity:0.6;">No active broadcast relays.</div>';
|
||||
} else {
|
||||
listEl.innerHTML = activeUrls.map((url) => `
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
|
||||
<span style="flex:1;word-break:break-all;">${escapeHtml(url)}</span>
|
||||
<button data-broadcast-remove="${escapeHtml(url)}" style="font-size:75%;padding:0 4px;">×</button>
|
||||
</div>
|
||||
`).join('');
|
||||
listEl.querySelectorAll('[data-broadcast-remove]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => removeBroadcastRelay(btn.getAttribute('data-broadcast-remove')));
|
||||
});
|
||||
}
|
||||
|
||||
// Skipped list
|
||||
if (skipped.length === 0) {
|
||||
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div><div style="font-size:75%;">None skipped.</div>';
|
||||
} else {
|
||||
skippedEl.innerHTML = '<div style="font-size:75%;margin-bottom:4px;">Skipped (failed publishes):</div>' +
|
||||
skipped.map((item) => {
|
||||
const ts = item.timestamp
|
||||
? new Date(item.timestamp * 1000).toLocaleString()
|
||||
: '';
|
||||
return `
|
||||
<div style="display:flex;align-items:center;gap:4px;padding:2px 0;font-size:75%;">
|
||||
<span style="flex:1;word-break:break-all;">
|
||||
${escapeHtml(item.url)}
|
||||
<div style="font-size:70%;opacity:0.8;">${escapeHtml(item.reason || 'failed')}${ts ? ' · ' + escapeHtml(ts) : ''}</div>
|
||||
</span>
|
||||
<button data-broadcast-unskip="${escapeHtml(item.url)}" style="font-size:75%;padding:0 4px;">Unskip</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
skippedEl.querySelectorAll('[data-broadcast-unskip]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => unskipRelay(btn.getAttribute('data-broadcast-unskip')));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const map = { '&': 38, '<': 60, '>': 62, '"': 34, "'": 39 };
|
||||
return String(s || '').replace(/[&<>"']/g, (c) =>
|
||||
'&#' + map[c] + ';'
|
||||
);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
RELAY TABLE FUNCTIONS
|
||||
================================================================ */
|
||||
@@ -942,7 +1184,9 @@ const versionInfo = await getVersion();
|
||||
if (!relayUrl) return;
|
||||
selectedRelayUrl = normalizeRelayUrlForDb(relayUrl);
|
||||
await createRelayTable(currentRelayList);
|
||||
await loadRelayEventsFromDb(selectedRelayUrl);
|
||||
if (connectionHistoryEnabled) {
|
||||
await loadRelayEventsFromDb(selectedRelayUrl);
|
||||
}
|
||||
renderRelayEventsForSelectedRelay();
|
||||
};
|
||||
|
||||
@@ -1267,10 +1511,112 @@ const versionInfo = await getVersion();
|
||||
}
|
||||
|
||||
if (selectedRelayUrl === normalizedRelayUrl) {
|
||||
renderRelayEventsForSelectedRelay();
|
||||
// Incremental append instead of full re-render
|
||||
const sideClass = side === 'client' ? 'client' : 'relay';
|
||||
const label = side === 'client' ? 'CLIENT ➜ RELAY' : 'RELAY ➜ CLIENT';
|
||||
const dataPart = summary ? `\n${summary}` : '';
|
||||
const text = `[${timestamp}] ${label} ${eventType.toUpperCase()}${dataPart}`;
|
||||
const row = document.createElement('div');
|
||||
row.className = `relay-event-row ${sideClass}`;
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'relay-event-bubble';
|
||||
bubble.textContent = text;
|
||||
row.appendChild(bubble);
|
||||
divRelayEvents.appendChild(row);
|
||||
|
||||
// Remove oldest if over 1000
|
||||
while (divRelayEvents.children.length > 1000) {
|
||||
divRelayEvents.removeChild(divRelayEvents.firstChild);
|
||||
}
|
||||
|
||||
divRelayEvents.scrollTop = divRelayEvents.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const shortNpub = (hex) => {
|
||||
if (!hex || hex.length < 16) return hex || '-';
|
||||
return `${hex.slice(0, 8)}…${hex.slice(-4)}`;
|
||||
};
|
||||
|
||||
let discoveredRelaysExpanded = false;
|
||||
|
||||
const renderDiscoveredRelays = (relays) => {
|
||||
const container = document.getElementById('divDiscoveredRelays');
|
||||
const title = document.getElementById('divDiscoveredRelaysTitle');
|
||||
if (!container || !title) return;
|
||||
|
||||
if (!discoveredRelaysExpanded) {
|
||||
container.style.display = 'none';
|
||||
title.textContent = `▸ Discovered Relays (Outbox)${relays.length > 0 ? ` (${relays.length})` : ''}`;
|
||||
return;
|
||||
}
|
||||
|
||||
title.textContent = `▾ Discovered Relays (Outbox) (${relays.length})`;
|
||||
container.style.display = '';
|
||||
|
||||
if (relays.length === 0) {
|
||||
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays. Load the feed page to populate outbox relays from your follows.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out relays with no serving pubkeys — if no follows use this
|
||||
// relay, there's no reason to show it in the discovered relays list.
|
||||
const relaysWithFollows = relays.filter(r =>
|
||||
Array.isArray(r.servingPubkeys) && r.servingPubkeys.length > 0
|
||||
);
|
||||
|
||||
if (relaysWithFollows.length === 0) {
|
||||
container.innerHTML = '<div style="padding:8px;color:var(--muted-color);font-size:80%;">No discovered relays with active follow data. Load the feed page to populate outbox relays from your follows.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table style="width:100%;font-size:70%;border-collapse:collapse;">';
|
||||
html += '<thead><tr>';
|
||||
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Relay</th>';
|
||||
html += '<th class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">Connected</th>';
|
||||
html += '<th class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">Serving</th>';
|
||||
html += '<th class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;">Follows</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
for (const relay of relaysWithFollows) {
|
||||
const connected = relay.connected ? SVG_CHECKED : SVG_UNCHECKED;
|
||||
const servingCount = Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.length : 0;
|
||||
const followsList = Array.isArray(relay.servingNames) && relay.servingNames.length > 0
|
||||
? relay.servingNames.join(', ')
|
||||
: (Array.isArray(relay.servingPubkeys) ? relay.servingPubkeys.map(shortNpub).join(', ') : '-');
|
||||
const rowClass = relay.connected ? 'tblRowConnected' : '';
|
||||
|
||||
html += `<tr class="${rowClass}">`;
|
||||
html += `<td class="tblCol tblColLeft tblColRelay" style="border:0.1px solid var(--muted-color);padding:4px;" title="${relay.url}">${relay.url}</td>`;
|
||||
html += `<td class="tblCol tblColCenter" style="border:0.1px solid var(--muted-color);padding:4px;">${connected}</td>`;
|
||||
html += `<td class="tblCol tblColRight" style="border:0.1px solid var(--muted-color);padding:4px;">${servingCount}</td>`;
|
||||
html += `<td class="tblCol tblColLeft" style="border:0.1px solid var(--muted-color);padding:4px;font-family:monospace;word-break:break-all;">${followsList}</td>`;
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
};
|
||||
|
||||
const refreshDiscoveredRelays = async () => {
|
||||
try {
|
||||
const relays = await getDiscoveredRelays();
|
||||
renderDiscoveredRelays(Array.isArray(relays) ? relays : []);
|
||||
} catch (error) {
|
||||
console.warn('[relays.html] Failed to fetch discovered relays:', error?.message || error);
|
||||
renderDiscoveredRelays([]);
|
||||
}
|
||||
};
|
||||
|
||||
const initDiscoveredRelaysToggle = () => {
|
||||
const title = document.getElementById('divDiscoveredRelaysTitle');
|
||||
if (!title) return;
|
||||
title.addEventListener('click', () => {
|
||||
discoveredRelaysExpanded = !discoveredRelaysExpanded;
|
||||
refreshDiscoveredRelays();
|
||||
});
|
||||
};
|
||||
|
||||
const refreshRelayData = async () => {
|
||||
console.log('[relay2.html] Refreshing relay data...');
|
||||
try {
|
||||
@@ -1386,6 +1732,10 @@ const versionInfo = await getVersion();
|
||||
|
||||
// Initialize NDK (handles authentication automatically)
|
||||
await initNDKPage();
|
||||
|
||||
// Load broadcast relays from the worker so the sidenav shows fresh
|
||||
// data the first time it is opened.
|
||||
loadBroadcastRelays();
|
||||
|
||||
// Get authenticated pubkey
|
||||
currentPubkey = await getPubkey();
|
||||
@@ -1403,6 +1753,7 @@ const versionInfo = await getVersion();
|
||||
// Note: We need to listen on the worker port, not window
|
||||
// This will be set up via a custom event from init-ndk.mjs
|
||||
window.addEventListener('ndkRelayEvent', (event) => {
|
||||
if (!connectionHistoryEnabled) return;
|
||||
const { relayUrl, event: eventType, data, timestamp, side } = event.detail;
|
||||
logRelayEvent(relayUrl, eventType, data, timestamp, side);
|
||||
});
|
||||
@@ -1433,6 +1784,9 @@ const versionInfo = await getVersion();
|
||||
|
||||
await loadCurrentDmInboxRelayList();
|
||||
|
||||
// Initialize discovered relays (outbox) collapsible toggle
|
||||
initDiscoveredRelaysToggle();
|
||||
|
||||
// Get initial relay data
|
||||
await refreshRelayData();
|
||||
|
||||
@@ -1444,7 +1798,27 @@ const versionInfo = await getVersion();
|
||||
|
||||
// Refresh relay table every 5 seconds
|
||||
setInterval(refreshRelayData, 5000);
|
||||
|
||||
|
||||
// Discovered relays are fetched only on manual expand click — no
|
||||
// auto-refresh interval, to avoid blocking the worker's message queue.
|
||||
|
||||
// Sync connection history toggle state from localStorage
|
||||
connectionHistoryEnabled = localStorage.getItem('relayConnectionHistory') === 'true';
|
||||
setRelayEventLogging(connectionHistoryEnabled);
|
||||
if (!connectionHistoryEnabled) {
|
||||
const wrap = document.getElementById('divRelayEventsWrap');
|
||||
if (wrap) {
|
||||
wrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the worker from broadcasting relay events when the page is closing
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (connectionHistoryEnabled) {
|
||||
setRelayEventLogging(false);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[relay2.html] Initialization complete');
|
||||
} catch (error) {
|
||||
console.error('[relay2.html] Initialization failed:', error);
|
||||
|
||||
@@ -639,7 +639,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -331,7 +331,7 @@
|
||||
</div>
|
||||
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
import {
|
||||
|
||||
@@ -255,7 +255,7 @@ note("<c a f e>(3,8)")
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -305,7 +305,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -508,7 +508,7 @@
|
||||
REQUIRED SCRIPTS
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -307,7 +307,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
@@ -1906,7 +1906,7 @@
|
||||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||||
================================================================ -->
|
||||
<script src="./nostr.bundle.js"></script>
|
||||
<script src="./nostr-lite.js"></script>
|
||||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||||
|
||||
<script type="module">
|
||||
/* ================================================================
|
||||
|
||||
Reference in New Issue
Block a user