Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57863fddcd | ||
|
|
7d500495a7 | ||
|
|
7da3b7047c | ||
|
|
360a4d83db | ||
|
|
9400508f3e | ||
|
|
7edee38ea1 | ||
|
|
1a17bef1ac | ||
|
|
04405da933 | ||
|
|
ee0077063d | ||
|
|
b09bf0d839 | ||
|
|
d04844cce8 | ||
|
|
b03b848909 | ||
|
|
9b746838c3 | ||
|
|
978e5029f3 | ||
|
|
011e4c303c | ||
|
|
fa019fe9a1 | ||
|
|
0f31e1c301 | ||
|
|
c9a20e63b5 | ||
|
|
79e38b8f79 | ||
|
|
7cfd43b91b | ||
|
|
777ab312a5 | ||
|
|
2763dd9d6a | ||
|
|
a20b28cd21 | ||
|
|
456f0e1f2e | ||
|
|
4abb4bf33c |
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
|
||||
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
|
||||
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
|
||||
@@ -179,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;
|
||||
@@ -449,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;
|
||||
}
|
||||
|
||||
@@ -479,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 };
|
||||
@@ -499,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(() => {});
|
||||
@@ -710,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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -392,6 +392,9 @@ function handleWorkerMessage(event) {
|
||||
failed: message.failed,
|
||||
latestRelay: message.latestRelay,
|
||||
latestError: message.latestError,
|
||||
relayUrls: message.relayUrls || [],
|
||||
batchCurrent: message.batchCurrent,
|
||||
batchTotal: message.batchTotal,
|
||||
}
|
||||
}));
|
||||
} else if (message.type === 'startupStatus') {
|
||||
|
||||
@@ -915,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 });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -932,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);
|
||||
@@ -1085,10 +1147,12 @@ export function renderFooterRow(eventId, eventData, options = {}) {
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"VERSION": "v0.7.68",
|
||||
"VERSION_NUMBER": "0.7.68",
|
||||
"BUILD_DATE": "2026-06-30T13:15:02.809Z"
|
||||
"VERSION": "v0.7.93",
|
||||
"VERSION_NUMBER": "0.7.93",
|
||||
"BUILD_DATE": "2026-07-03T10:29:34.780Z"
|
||||
}
|
||||
|
||||
@@ -1049,9 +1049,8 @@ function pruneSeenEventIds(seenEventIds) {
|
||||
function wireSubscriptionHandlers(subId, state, filtersForCache) {
|
||||
if (!state?.sub) return;
|
||||
|
||||
state.sub.on('event', async (event) => {
|
||||
state.sub.on('event', (event) => {
|
||||
if (state.seenEventIds.has(event.id)) {
|
||||
console.log('[Worker] Dedup dropped event for sub:', subId, 'id:', event.id);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1063,14 +1062,7 @@ function wireSubscriptionHandlers(subId, state, filtersForCache) {
|
||||
state.lastEventCreatedAt = Math.max(Number(state.lastEventCreatedAt || 0), createdAt);
|
||||
}
|
||||
|
||||
console.log('[Worker] Event received for sub:', subId, 'kind:', event.kind, 'id:', event.id);
|
||||
|
||||
if (isWorkerEventMuted(event)) {
|
||||
console.log('[Worker][mute] Filtered muted event for sub:', subId, {
|
||||
id: event?.id || null,
|
||||
kind: event?.kind || null,
|
||||
pubkey: event?.pubkey || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1079,14 +1071,14 @@ function wireSubscriptionHandlers(subId, state, filtersForCache) {
|
||||
trackRelayRead(event.relay.url);
|
||||
}
|
||||
|
||||
// Cache the event to IndexedDB
|
||||
// Cache the event to IndexedDB — fire-and-forget (NOT awaited).
|
||||
// Awaiting serializes all incoming events through IndexedDB I/O,
|
||||
// which blocks the worker's event loop and starves port.onmessage
|
||||
// handlers (getRelayData, getRelayStats, etc.), causing 15s timeouts
|
||||
// and relay indicators disappearing over extended sessions.
|
||||
if (ndk.cacheAdapter && typeof ndk.cacheAdapter.setEvent === 'function') {
|
||||
try {
|
||||
await ndk.cacheAdapter.setEvent(event, filtersForCache, event.relay);
|
||||
console.log('[Worker] Cached event:', event.id, 'from relay:', event.relay?.url);
|
||||
} catch (error) {
|
||||
console.error('[Worker] Failed to cache event:', error);
|
||||
}
|
||||
void ndk.cacheAdapter.setEvent(event, filtersForCache, event.relay)
|
||||
.catch(error => console.error('[Worker] Failed to cache event:', error));
|
||||
}
|
||||
|
||||
broadcast({
|
||||
@@ -4616,8 +4608,13 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
const meltQuote = await wallet.createMeltQuote(pr);
|
||||
const quoteAmount = Number(meltQuote?.amount || 0);
|
||||
const quoteFeeReserve = Number(meltQuote?.fee_reserve || 0);
|
||||
// The mint's quoted fee_reserve is an estimate; the actual melt fee can be
|
||||
// slightly higher, causing "not enough inputs provided for melt" errors.
|
||||
// Add a safety buffer — any overpayment is returned as change proofs.
|
||||
const FEE_RESERVE_BUFFER_SATS = 10;
|
||||
const required = quoteAmount + quoteFeeReserve;
|
||||
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required });
|
||||
const sendAmount = required + FEE_RESERVE_BUFFER_SATS;
|
||||
log('mint attempt:quote', { mintUrl, quoteAmount, quoteFeeReserve, required, sendAmount });
|
||||
|
||||
if (!Number.isFinite(required) || required <= 0) {
|
||||
log('mint attempt:invalid-quote', { mintUrl, required });
|
||||
@@ -4625,28 +4622,28 @@ async function handleWalletPayInvoice(requestId, invoice, mint, port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (proofBalance < required) {
|
||||
log('mint attempt:insufficient', { mintUrl, proofBalance, required });
|
||||
if (proofBalance < sendAmount) {
|
||||
log('mint attempt:insufficient', { mintUrl, proofBalance, sendAmount });
|
||||
attemptErrors.push({ mint: mintUrl, message: `Insufficient funds on mint (${proofBalance} sats)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const sendResult = await wallet.send(required, proofs);
|
||||
const sendResult = await wallet.send(sendAmount, proofs);
|
||||
const keep = Array.isArray(sendResult?.keep) ? sendResult.keep : [];
|
||||
const sendProofs = Array.isArray(sendResult?.send) ? sendResult.send : [];
|
||||
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length });
|
||||
log('mint attempt:send-split', { mintUrl, keepCount: keep.length, sendCount: sendProofs.length, sendAmount });
|
||||
|
||||
const meltResult = await wallet.meltProofs(meltQuote, sendProofs);
|
||||
const change = Array.isArray(meltResult?.change) ? meltResult.change : [];
|
||||
upsertMintProofs(mintUrl, [...keep, ...change]);
|
||||
|
||||
const changeAmount = getProofTotal(change);
|
||||
const spentAmount = Math.max(0, required - changeAmount);
|
||||
const spentAmount = Math.max(0, sendAmount - changeAmount);
|
||||
paidAmountSats = Number.isFinite(quoteAmount) && quoteAmount > 0
|
||||
? quoteAmount
|
||||
: spentAmount;
|
||||
|
||||
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, preimage: meltResult?.preimage || null });
|
||||
log('mint attempt:success', { mintUrl, paidAmountSats, changeAmount, spentAmount, preimage: meltResult?.preimage || null });
|
||||
|
||||
usedMint = mintUrl;
|
||||
payResult = meltResult;
|
||||
@@ -6187,7 +6184,6 @@ async function handlePublish(requestId, event, port) {
|
||||
// temporary NDKRelaySet. NDK's fromRelayUrls uses pool.useTemporaryRelay
|
||||
// so hundreds of broadcast relays connect transiently without polluting
|
||||
// the read subscription pool.
|
||||
let targetRelaySet = null;
|
||||
const activeBroadcastUrls = getActiveBroadcastUrls();
|
||||
const isBroadcast = wantsBroadcastRelays(event) && activeBroadcastUrls.length > 0;
|
||||
let outboxWriteUrls = [];
|
||||
@@ -6196,31 +6192,30 @@ async function handlePublish(requestId, event, port) {
|
||||
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)`);
|
||||
} else {
|
||||
console.warn('[Worker] NDKRelaySet.fromRelayUrls unavailable, falling back to default outbox');
|
||||
}
|
||||
}
|
||||
const allBroadcastUrls = [...new Set([...outboxWriteUrls, ...activeBroadcastUrls])];
|
||||
|
||||
// --- 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 all pages via
|
||||
// broadcast() so footers can show "📡 42/150 relays · relay.example.com".
|
||||
// relay as each completes. We attach listeners BEFORE publish() and stream
|
||||
// progress to all pages via broadcast() so footers can show the count.
|
||||
const publishSessionId = `pub_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const publishedSoFar = new Set();
|
||||
const failedSoFar = new Set(); // explicit rejections (auth-required, blocked, etc.)
|
||||
const timedOutSoFar = new Set(); // timeouts — NOT skip-marked (could be slow connect)
|
||||
const totalTarget = targetRelaySet && targetRelaySet.relays
|
||||
? targetRelaySet.relays.size
|
||||
: (targetRelaySet ? (targetRelaySet.size || 0) : 0);
|
||||
const timedOutSoFar = new Set(); // timeouts
|
||||
const totalTarget = isBroadcast ? allBroadcastUrls.length : 0;
|
||||
// Track current batch for progress display. Updated by the batch loop.
|
||||
let currentBatch = 0;
|
||||
let totalBatches = 0;
|
||||
// Debug: track publish start time for timing analysis (defined in outer
|
||||
// scope so the batch loop and silent-failure detection can access it).
|
||||
const publishStartTime = Date.now();
|
||||
|
||||
if (isBroadcast && totalTarget > 0) {
|
||||
console.log(`[Worker] Broadcasting to ${allBroadcastUrls.length} relays ` +
|
||||
`(${activeBroadcastUrls.length} broadcast + ${outboxWriteUrls.length} outbox, ` +
|
||||
`${skippedRelayUrls.size} skipped)`);
|
||||
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
@@ -6236,16 +6231,20 @@ async function handlePublish(requestId, event, port) {
|
||||
ndkEvent.on('relay:published', (relay) => {
|
||||
const url = relay?.url || String(relay || '');
|
||||
if (!url) return;
|
||||
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
|
||||
publishedSoFar.add(url);
|
||||
trackRelayWrite(url);
|
||||
console.log(`[Worker][broadcast] ✅ ${url} published (${elapsed}s) — batch ${currentBatch}/${totalBatches}, ${publishedSoFar.size}/${totalTarget} total`);
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size,
|
||||
failed: failedSoFar.size + timedOutSoFar.size,
|
||||
latestRelay: url,
|
||||
batchCurrent: currentBatch,
|
||||
batchTotal: totalBatches,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6253,15 +6252,14 @@ async function handlePublish(requestId, event, port) {
|
||||
const url = relay?.url || String(relay || '');
|
||||
if (!url) return;
|
||||
const errMsg = err?.message || String(err) || '';
|
||||
// Distinguish timeouts from explicit rejections. Timeouts with
|
||||
// hundreds of relays often just mean the relay was slow to connect,
|
||||
// not that it's broken — so we track them separately and do NOT
|
||||
// skip-mark them. Only explicit rejections go into failedSoFar.
|
||||
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
|
||||
const isTimeout = /timeout/i.test(errMsg);
|
||||
if (isTimeout) {
|
||||
timedOutSoFar.add(url);
|
||||
console.log(`[Worker][broadcast] ⏱️ ${url} TIMEOUT (${elapsed}s) — batch ${currentBatch}/${totalBatches}`);
|
||||
} else {
|
||||
failedSoFar.add(url);
|
||||
console.log(`[Worker][broadcast] ❌ ${url} REJECTED (${elapsed}s): ${errMsg} — batch ${currentBatch}/${totalBatches}`);
|
||||
}
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
@@ -6272,50 +6270,208 @@ async function handlePublish(requestId, event, port) {
|
||||
failed: failedSoFar.size + timedOutSoFar.size,
|
||||
latestRelay: url,
|
||||
latestError: errMsg,
|
||||
batchCurrent: currentBatch,
|
||||
batchTotal: totalBatches,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Publish to relays (with broadcast relay set if broadcasting, else default outbox).
|
||||
// When broadcasting to many relays (potentially hundreds), use a much longer
|
||||
// timeout than NDK's default 4400ms — temporary relays need time to establish
|
||||
// WebSocket connections. Use 30s for broadcasts, default for normal publishes.
|
||||
// requiredRelayCount=1 so NDK doesn't throw if only 1 of 600 relays succeeds.
|
||||
const BROADCAST_TIMEOUT_MS = 30000;
|
||||
const publishTimeoutMs = isBroadcast ? BROADCAST_TIMEOUT_MS : undefined;
|
||||
const publishRequiredCount = isBroadcast ? 1 : undefined;
|
||||
// --- Batched publishing for broadcasts ------------------------------------
|
||||
// Browsers can only handle ~50-100 simultaneous WebSocket connections.
|
||||
// Publishing to 600+ relays in one shot causes most connections to queue
|
||||
// and time out before getting a slot. We split the relay URLs into batches
|
||||
// of 25 and publish to each batch sequentially, keeping the number of
|
||||
// simultaneous connections manageable.
|
||||
const BATCH_SIZE = 25;
|
||||
const PER_RELAY_TIMEOUT_MS = 10000; // 10s per relay within a batch
|
||||
|
||||
let relaySet;
|
||||
try {
|
||||
relaySet = await ndkEvent.publish(targetRelaySet, publishTimeoutMs, publishRequiredCount);
|
||||
} catch (publishError) {
|
||||
// NDK throws NDKPublishError if requiredRelayCount isn't met. With
|
||||
// requiredRelayCount=1 this shouldn't happen for broadcasts, but handle
|
||||
// it gracefully — the live progress listeners already captured whatever
|
||||
// succeeded. Log and continue so we still report results to the page.
|
||||
console.warn('[Worker] publish() threw (some relays may still have succeeded):', publishError?.message || publishError);
|
||||
relaySet = null;
|
||||
}
|
||||
let relaySet = null;
|
||||
if (isBroadcast && totalTarget > 0 && NDKRelaySet?.fromRelayUrls) {
|
||||
// --- Phase 1: Publish to the user's standard outbox relays first ---
|
||||
// This ensures the post lands on the user's primary relays quickly,
|
||||
// and lets us send the response back to the page so the composer
|
||||
// clears immediately. The broadcast relay batches run afterward.
|
||||
if (outboxWriteUrls.length > 0) {
|
||||
console.log(`[Worker] Phase 1: Publishing to ${outboxWriteUrls.length} outbox relays first`);
|
||||
try {
|
||||
const outboxSet = NDKRelaySet.fromRelayUrls(outboxWriteUrls, ndk);
|
||||
await ndkEvent.publish(outboxSet, 10000, 1);
|
||||
} catch (outboxErr) {
|
||||
console.warn('[Worker] Outbox publish error (expected if some relays fail):', outboxErr?.message || outboxErr);
|
||||
}
|
||||
console.log(`[Worker] Phase 1 complete: ${publishedSoFar.size} outbox relays succeeded`);
|
||||
}
|
||||
|
||||
// Final broadcast progress message with complete results.
|
||||
if (isBroadcast && totalTarget > 0) {
|
||||
// --- Send response back to the page NOW so the composer clears ---
|
||||
// The post is already on the user's outbox relays. The broadcast
|
||||
// batches continue in the background.
|
||||
const earlyRelayResults = {
|
||||
successful: Array.from(publishedSoFar),
|
||||
failed: [],
|
||||
};
|
||||
port.postMessage({
|
||||
type: 'response',
|
||||
requestId: requestId,
|
||||
data: {
|
||||
success: true,
|
||||
relayResults: earlyRelayResults,
|
||||
totalRelays: earlyRelayResults.successful.length,
|
||||
}
|
||||
});
|
||||
console.log('[Worker] Sent early publish response — composer can clear now');
|
||||
|
||||
// --- Phase 2: Broadcast to remaining relays in batches (background) ---
|
||||
// Only publish to relays that aren't already in the outbox set
|
||||
// (those were already published in Phase 1).
|
||||
const broadcastOnlyUrls = activeBroadcastUrls.filter(u => !outboxWriteUrls.includes(u));
|
||||
if (broadcastOnlyUrls.length > 0) {
|
||||
const batches = [];
|
||||
for (let i = 0; i < broadcastOnlyUrls.length; i += BATCH_SIZE) {
|
||||
batches.push(broadcastOnlyUrls.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
totalBatches = batches.length;
|
||||
console.log(`[Worker] Phase 2: Broadcasting to ${broadcastOnlyUrls.length} relays in ${totalBatches} batches of ${BATCH_SIZE}`);
|
||||
|
||||
// Emit a progress event showing batch info before starting batches.
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size + timedOutSoFar.size,
|
||||
latestRelay: null,
|
||||
batchCurrent: 0,
|
||||
batchTotal: totalBatches,
|
||||
});
|
||||
|
||||
for (let bi = 0; bi < batches.length; bi++) {
|
||||
currentBatch = bi + 1; // update before publish so per-relay events include it
|
||||
const batch = batches[bi];
|
||||
const batchStartTime = Date.now();
|
||||
console.log(`[Worker][broadcast] Batch ${bi + 1}/${totalBatches} starting — ${batch.length} relays: ${batch.slice(0, 5).join(', ')}${batch.length > 5 ? '...' : ''}`);
|
||||
const batchSet = NDKRelaySet.fromRelayUrls(batch, ndk);
|
||||
if (batchSet?.relays) {
|
||||
for (const relay of batchSet.relays) {
|
||||
try {
|
||||
if (relay.connectionTimeout !== undefined) {
|
||||
relay.connectionTimeout = PER_RELAY_TIMEOUT_MS;
|
||||
}
|
||||
} catch (_) { /* relay may be read-only */ }
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ndkEvent.publish(batchSet, PER_RELAY_TIMEOUT_MS, 1);
|
||||
} catch (batchErr) {
|
||||
// Expected — some relays in this batch may fail.
|
||||
}
|
||||
// --- Detect silent failures ---
|
||||
// NDK's relay.publish() can resolve false without emitting
|
||||
// relay:publish:failed (e.g., DNS errors, connection refused,
|
||||
// HTTP handshake failures). These relays are not in any of
|
||||
// our tracked sets. Mark them as timed out so they get
|
||||
// skip-marked and don't waste time on the next publish.
|
||||
for (const url of batch) {
|
||||
if (!publishedSoFar.has(url) && !failedSoFar.has(url) && !timedOutSoFar.has(url)) {
|
||||
timedOutSoFar.add(url);
|
||||
const elapsed = ((Date.now() - publishStartTime) / 1000).toFixed(1);
|
||||
console.log(`[Worker][broadcast] ⏱️ ${url} SILENT TIMEOUT (${elapsed}s) — batch ${currentBatch}/${totalBatches} (no response, likely connection failure)`);
|
||||
}
|
||||
}
|
||||
// Emit a progress event with batch info after each batch.
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
phase: 'progress',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: failedSoFar.size + timedOutSoFar.size,
|
||||
latestRelay: null,
|
||||
batchCurrent: bi + 1,
|
||||
batchTotal: totalBatches,
|
||||
});
|
||||
const batchElapsed = ((Date.now() - batchStartTime) / 1000).toFixed(1);
|
||||
console.log(`[Worker] Batch ${bi + 1}/${totalBatches} complete (${batchElapsed}s) — ` +
|
||||
`${publishedSoFar.size}/${totalTarget} total succeeded, ` +
|
||||
`${failedSoFar.size + timedOutSoFar.size} failed/timeout so far`);
|
||||
}
|
||||
}
|
||||
|
||||
// Final broadcast progress message with complete results.
|
||||
const totalFailed = failedSoFar.size + timedOutSoFar.size;
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
eventId: ndkEvent.id || null,
|
||||
eventKind: event.kind,
|
||||
phase: 'done',
|
||||
total: totalTarget,
|
||||
successful: publishedSoFar.size,
|
||||
failed: totalFailed,
|
||||
latestRelay: null,
|
||||
relayUrls: Array.from(publishedSoFar),
|
||||
});
|
||||
// Clean up listeners so the NDKEvent can be garbage-collected.
|
||||
try {
|
||||
ndkEvent.removeAllListeners('relay:published');
|
||||
ndkEvent.removeAllListeners('relay:publish:failed');
|
||||
} catch (_cleanupErr) {
|
||||
// removeAllListeners may not exist on all EventEmitter impls; ignore.
|
||||
} catch (_cleanupErr) { /* ignore */ }
|
||||
|
||||
// --- Failure marking (background, after all batches) ---
|
||||
// Skip-mark both explicit rejections AND timeouts. A relay that
|
||||
// times out is either down, too slow, or unreachable — skip it
|
||||
// next time to avoid wasting time on it.
|
||||
const allFailed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
|
||||
const newSkips = allFailed
|
||||
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
|
||||
if (newSkips.length > 0) {
|
||||
for (const url of newSkips) {
|
||||
const isTimeout = timedOutSoFar.has(url);
|
||||
skippedRelayUrls.set(url, {
|
||||
reason: isTimeout ? 'publish-timeout' : 'publish-rejected',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
scheduleSkipMarkSave();
|
||||
const timeoutCount = newSkips.filter(u => timedOutSoFar.has(u)).length;
|
||||
const rejectCount = newSkips.length - timeoutCount;
|
||||
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays ` +
|
||||
`(${rejectCount} rejected, ${timeoutCount} timed out)`);
|
||||
}
|
||||
|
||||
// Return early — we already sent the response above. The rest of
|
||||
// handlePublish (relayResults, kind 10002 sync, etc.) is skipped for
|
||||
// broadcasts since we've handled everything here.
|
||||
return;
|
||||
} else {
|
||||
// Normal (non-broadcast) publish — await the full result as before.
|
||||
try {
|
||||
relaySet = await ndkEvent.publish(null, undefined, undefined);
|
||||
} catch (publishError) {
|
||||
console.warn('[Worker] publish() threw:', publishError?.message || publishError);
|
||||
relaySet = null;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-broadcast publishes, emit a simplified broadcastProgress 'done'
|
||||
// event so post cards can still show the relay count (e.g., "3r - 1h").
|
||||
// We use the relaySet result to count successful relays.
|
||||
if (!isBroadcast) {
|
||||
const successCount = relaySet ? relaySet.size : 0;
|
||||
const successUrls = relaySet
|
||||
? Array.from(relaySet).map(r => r?.url || '').filter(Boolean)
|
||||
: [];
|
||||
broadcast({
|
||||
type: 'broadcastProgress',
|
||||
sessionId: publishSessionId,
|
||||
eventId: ndkEvent.id || null,
|
||||
eventKind: event.kind,
|
||||
phase: 'done',
|
||||
total: successCount,
|
||||
successful: successCount,
|
||||
failed: 0,
|
||||
latestRelay: null,
|
||||
relayUrls: successUrls,
|
||||
});
|
||||
}
|
||||
|
||||
// Get detailed relay results
|
||||
@@ -6355,27 +6511,25 @@ async function handlePublish(requestId, event, port) {
|
||||
console.log('[Worker] ❌ Failed relays:', relayResults.failed.length);
|
||||
|
||||
// --- Failure marking for broadcast relays ---------------------------------
|
||||
// Persist skip-marks ONLY for broadcast relays that EXPLICITLY rejected
|
||||
// the publish (auth-required, blocked, invalid, etc.). We do NOT skip-mark
|
||||
// relays that timed out — with hundreds of relays, a timeout often means
|
||||
// the relay was slow to connect, not that it's broken. The relay:publish:failed
|
||||
// listener above separates timeouts (timedOutSoFar) from explicit rejections
|
||||
// (failedSoFar) by checking the error message for "timeout".
|
||||
// Skip-mark both explicit rejections AND timeouts. A relay that times
|
||||
// out is either down, too slow, or unreachable — skip it next time.
|
||||
if (isBroadcast) {
|
||||
const newSkips = Array.from(failedSoFar)
|
||||
const allFailed = [...Array.from(failedSoFar), ...Array.from(timedOutSoFar)];
|
||||
const newSkips = allFailed
|
||||
.filter(url => broadcastRelayUrls.has(url) && !skippedRelayUrls.has(url));
|
||||
if (newSkips.length > 0) {
|
||||
for (const url of newSkips) {
|
||||
const isTimeout = timedOutSoFar.has(url);
|
||||
skippedRelayUrls.set(url, {
|
||||
reason: 'publish-rejected',
|
||||
reason: isTimeout ? 'publish-timeout' : 'publish-rejected',
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
scheduleSkipMarkSave();
|
||||
console.log(`[Worker] Skip-marked ${newSkips.length} rejected broadcast relays ` +
|
||||
`(${timedOutSoFar.size} timeouts excluded from skip-marking)`);
|
||||
} else if (timedOutSoFar.size > 0) {
|
||||
console.log(`[Worker] ${timedOutSoFar.size} broadcast relays timed out (not skip-marked — will retry next publish)`);
|
||||
const timeoutCount2 = newSkips.filter(u => timedOutSoFar.has(u)).length;
|
||||
const rejectCount2 = newSkips.length - timeoutCount2;
|
||||
console.log(`[Worker] Skip-marked ${newSkips.length} failed broadcast relays ` +
|
||||
`(${rejectCount2} rejected, ${timeoutCount2} timed out)`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,32 @@
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.notif-menu-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: var(--button-border-width) solid var(--button-border-color);
|
||||
border-radius: var(--button-border-radius);
|
||||
background: var(--button-background-color);
|
||||
color: var(--button-color);
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 90%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notif-menu-item:hover {
|
||||
color: var(--button-hover-color);
|
||||
border-color: var(--button-hover-color);
|
||||
}
|
||||
|
||||
.notif-menu-divider {
|
||||
height: 1px;
|
||||
background: var(--muted-color);
|
||||
margin: 4px 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.svgBtn {
|
||||
fill: none;
|
||||
stroke: var(--button-color);
|
||||
@@ -377,7 +403,13 @@
|
||||
publishEvent,
|
||||
ensureMuteListLoaded,
|
||||
isEventMuted,
|
||||
addMute
|
||||
addMute,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData
|
||||
} from './js/init-ndk.mjs';
|
||||
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
|
||||
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
|
||||
@@ -385,7 +417,7 @@
|
||||
|
||||
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
|
||||
import { createProfileCache } from './js/profile-cache.mjs';
|
||||
import { formatTimeAgo } from './js/post-interactions.mjs';
|
||||
import { formatTimeAgo, initInteractions } from './js/post-interactions.mjs';
|
||||
|
||||
const versionInfo = await getVersion();
|
||||
const VERSION = versionInfo.VERSION;
|
||||
@@ -393,6 +425,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 = [
|
||||
@@ -422,6 +456,10 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
const unreadNotificationsById = new Map();
|
||||
const timeElements = new Map();
|
||||
const postImageCache = new Map();
|
||||
const targetEventCache = new Map();
|
||||
const detachedInteractionBars = new Map();
|
||||
let interactions = null;
|
||||
let notifInteractionsSubId = null;
|
||||
let visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
|
||||
|
||||
const profileCache = createProfileCache({
|
||||
@@ -441,6 +479,40 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
const divFooterRight = document.getElementById('divFooterRight');
|
||||
const divNotificationFiltersList = document.getElementById('divNotificationFiltersList');
|
||||
|
||||
// Initialize the post-interactions module so notification dropdown menus
|
||||
// can offer Like / Zap / Nutzap / Quote / Comment on the referenced post.
|
||||
// The interaction bar itself is rendered hidden inside each notification
|
||||
// row; menu items trigger clicks on its buttons.
|
||||
interactions = initInteractions({
|
||||
subscribe,
|
||||
publishEvent,
|
||||
getPubkey,
|
||||
ndkFetchEvents,
|
||||
queryCache,
|
||||
fetchCachedProfile,
|
||||
storeProfile,
|
||||
walletPayInvoice,
|
||||
walletSendNutzap,
|
||||
walletFetchMintList,
|
||||
walletGetMints,
|
||||
walletGetBalance,
|
||||
getRelayData,
|
||||
getUserSettings,
|
||||
patchUserSettings,
|
||||
onCommentIntent: ({ postId }) => {
|
||||
if (!postId) return false;
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
||||
return true;
|
||||
},
|
||||
onMuteIntent: async ({ eventData }) => {
|
||||
const targetPubkey = String(eventData?.pubkey || '').trim();
|
||||
if (!targetPubkey || targetPubkey === currentPubkey) return;
|
||||
const ok = window.confirm(`Block ${targetPubkey.slice(0, 8)}…${targetPubkey.slice(-4)}?`);
|
||||
if (!ok) return;
|
||||
await blockActorPubkey(targetPubkey);
|
||||
}
|
||||
});
|
||||
|
||||
function initHamburgerMenu() {
|
||||
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
|
||||
foreground: 'var(--primary-color)',
|
||||
@@ -883,6 +955,252 @@ 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';
|
||||
}
|
||||
});
|
||||
|
||||
// Add interaction menu items (Like, Comment, Quote, Zap, Nutzap) when
|
||||
// the notification references a target post. The actual interaction
|
||||
// logic (publishing, zap dialogs, balance checks) is handled by the
|
||||
// post-interactions module via a detached interaction bar (kept out of
|
||||
// the DOM so it never shows up in the row). Menu items trigger clicks
|
||||
// on the corresponding buttons in that detached bar.
|
||||
if (item.targetEventId && interactions) {
|
||||
const targetPubkey = item.targetPubkey || currentPubkey;
|
||||
const targetEventData = { id: item.targetEventId, pubkey: targetPubkey };
|
||||
|
||||
let hiddenBar = detachedInteractionBars.get(item.id);
|
||||
if (!hiddenBar) {
|
||||
hiddenBar = interactions.renderInteractionBar(
|
||||
item.targetEventId,
|
||||
targetEventData,
|
||||
{ currentPubkey }
|
||||
);
|
||||
detachedInteractionBars.set(item.id, hiddenBar);
|
||||
}
|
||||
|
||||
const addMenuBtn = (label, selector) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'notif-menu-item';
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
const target = hiddenBar.querySelector(selector);
|
||||
if (target) target.click();
|
||||
menuPanel.style.display = 'none';
|
||||
});
|
||||
menuPanel.appendChild(btn);
|
||||
};
|
||||
|
||||
addMenuBtn('Like', '.interaction-item.like');
|
||||
|
||||
// Comment opens the post-feed thread page in a new tab.
|
||||
const commentBtn = document.createElement('button');
|
||||
commentBtn.type = 'button';
|
||||
commentBtn.className = 'notif-menu-item';
|
||||
commentBtn.textContent = 'Comment';
|
||||
commentBtn.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(item.targetEventId), '_blank');
|
||||
menuPanel.style.display = 'none';
|
||||
});
|
||||
menuPanel.appendChild(commentBtn);
|
||||
|
||||
addMenuBtn('Quote', '.interaction-item.quote');
|
||||
addMenuBtn('Zap', '.interaction-item.zap');
|
||||
addMenuBtn('Nutzap', '.interaction-item.nutzap');
|
||||
|
||||
const divider = document.createElement('hr');
|
||||
divider.className = 'notif-menu-divider';
|
||||
menuPanel.appendChild(divider);
|
||||
}
|
||||
|
||||
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 wireNotificationInteractions() {
|
||||
if (!interactions) return;
|
||||
|
||||
const targetIds = Array.from(new Set(
|
||||
Array.from(notificationsById.values())
|
||||
.map((item) => item.targetEventId)
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (targetIds.length === 0) return;
|
||||
|
||||
interactions.fetchExistingInteractions(targetIds, { currentPubkey });
|
||||
|
||||
if (notifInteractionsSubId) {
|
||||
interactions.unsubscribeFromInteractions(notifInteractionsSubId);
|
||||
notifInteractionsSubId = null;
|
||||
}
|
||||
|
||||
notifInteractionsSubId = interactions.subscribeToInteractions(targetIds, {
|
||||
currentPubkey
|
||||
});
|
||||
}
|
||||
|
||||
function renderNotifications() {
|
||||
const items = Array.from(notificationsById.values())
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
|
||||
@@ -890,174 +1208,10 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
|
||||
divNotifications.innerHTML = '';
|
||||
timeElements.clear();
|
||||
detachedInteractionBars.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);
|
||||
}
|
||||
|
||||
@@ -1069,6 +1223,61 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
updateHint();
|
||||
refreshTimes();
|
||||
renderNotificationFilters();
|
||||
wireNotificationInteractions();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (item.targetEventId && interactions) {
|
||||
interactions.fetchExistingInteractions([item.targetEventId], { currentPubkey });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function blockActorPubkey(pubkey) {
|
||||
@@ -1092,6 +1301,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;
|
||||
@@ -1127,7 +1359,46 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
|
||||
const targetEventId = getNotificationTargetEventId(evt);
|
||||
|
||||
notificationsById.set(evt.id, {
|
||||
// Resolve the target event's pubkey so interaction handlers (like,
|
||||
// zap, nutzap) target the correct post author. Most notifications
|
||||
// reference the current user's own post, so default to currentPubkey
|
||||
// and refine from cache/relays when available.
|
||||
let targetPubkey = currentPubkey;
|
||||
if (targetEventId) {
|
||||
if (targetEventCache.has(targetEventId)) {
|
||||
const cachedTarget = targetEventCache.get(targetEventId);
|
||||
if (cachedTarget?.pubkey) targetPubkey = cachedTarget.pubkey;
|
||||
} else {
|
||||
try {
|
||||
const cachedRefs = await queryCache({ ids: [targetEventId], limit: 1 });
|
||||
const targetEvt = Array.isArray(cachedRefs)
|
||||
? cachedRefs.find((e) => e?.id === targetEventId)
|
||||
: null;
|
||||
if (targetEvt) {
|
||||
targetEventCache.set(targetEventId, targetEvt);
|
||||
if (targetEvt.pubkey) targetPubkey = targetEvt.pubkey;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Async relay hydration (non-blocking).
|
||||
void ndkFetchEvents({ ids: [targetEventId], limit: 1 })
|
||||
.then((relayRefs) => {
|
||||
const relayEvt = Array.isArray(relayRefs)
|
||||
? relayRefs.find((e) => e?.id === targetEventId)
|
||||
: null;
|
||||
if (relayEvt) {
|
||||
targetEventCache.set(targetEventId, relayEvt);
|
||||
const existing = notificationsById.get(evt.id);
|
||||
if (existing && existing.targetPubkey !== relayEvt.pubkey) {
|
||||
existing.targetPubkey = relayEvt.pubkey;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const notifItem = {
|
||||
id: evt.id,
|
||||
created_at: createdAt,
|
||||
kind,
|
||||
@@ -1135,10 +1406,21 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
secondaryImage,
|
||||
description,
|
||||
targetEventId,
|
||||
targetPubkey,
|
||||
isUnread: createdAt > notificationsReadAt
|
||||
});
|
||||
};
|
||||
|
||||
renderNotifications();
|
||||
notificationsById.set(evt.id, notifItem);
|
||||
|
||||
if (divNotifications.children.length > 0) {
|
||||
prependNotificationRow(notifItem);
|
||||
} else {
|
||||
renderNotifications();
|
||||
}
|
||||
|
||||
if (notificationsById.size > MAX_STORED_NOTIFICATIONS + 100) {
|
||||
pruneNotificationMaps();
|
||||
}
|
||||
}
|
||||
|
||||
function eventTargetsCurrentPubkey(evt) {
|
||||
@@ -1162,6 +1444,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 +1468,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
await addNotificationFromEvent(evt);
|
||||
}
|
||||
|
||||
pruneNotificationMaps();
|
||||
|
||||
console.log('[notifications] relay hydration complete', {
|
||||
relayCount: sorted.length
|
||||
});
|
||||
@@ -1219,6 +1505,7 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
notificationsReadAt = readAt;
|
||||
notificationsById.clear();
|
||||
unreadNotificationsById.clear();
|
||||
detachedInteractionBars.clear();
|
||||
visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
|
||||
renderNotifications();
|
||||
|
||||
@@ -1275,6 +1562,11 @@ import { createProfileCache } from './js/profile-cache.mjs';
|
||||
notificationsSub = null;
|
||||
}
|
||||
|
||||
if (notifInteractionsSubId && interactions) {
|
||||
interactions.unsubscribeFromInteractions(notifInteractionsSubId);
|
||||
notifInteractionsSubId = null;
|
||||
}
|
||||
|
||||
disconnect();
|
||||
|
||||
if (window.NOSTR_LOGIN_LITE?.logout) {
|
||||
@@ -1297,6 +1589,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');
|
||||
|
||||
108
www/post.html
108
www/post.html
@@ -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();
|
||||
@@ -490,17 +491,9 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
}
|
||||
|
||||
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, '');
|
||||
if (!postId) return false;
|
||||
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleComposerQuoteIntent({ postId, postPubkey }) {
|
||||
@@ -612,11 +605,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 +658,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;
|
||||
@@ -831,14 +899,14 @@ import { initPostCards } from './js/post-interactions2.mjs';
|
||||
const shortRelay = d.latestRelay
|
||||
? d.latestRelay.replace(/^wss?:\/\//, '').replace(/\/.*$/, '').slice(0, 30)
|
||||
: '';
|
||||
spanBroadcastStatus.textContent = `📡 ${d.successful}/${d.total} relays · ${shortRelay}`;
|
||||
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)` : '');
|
||||
setTimeout(() => {
|
||||
if (spanBroadcastStatus.textContent.startsWith('✅')) {
|
||||
spanBroadcastStatus.textContent = '';
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -909,7 +977,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) {
|
||||
|
||||
Reference in New Issue
Block a user