The template browser was returning zero results because kind 30078
(NIP-78 app data) is shared by many unrelated apps. A bare
{kinds:[30078], limit:200} query was getting flooded by other apps'
events (northlight-fragment alone had 300+ per relay), pushing
casewrap templates past the cutoff.
Narrow the relay-side query with #t:['casewrap'] for templates and
#k:['30078'] for deletions so the limit applies to this app only.
Also ships several supporting fixes that came out of the same
debugging session:
- queryRelays: add onEvent / onRelayDone streaming callbacks so
templates render as they arrive instead of after every relay
settles. Fast relays now populate the feed in < 1s.
- markRelayFailure: accept a context arg ('read' vs 'publish').
Read failures use 30s base / 5min max cooldowns (was 10min / 1hr).
A single transient read timeout no longer silently shrinks the
relay pool for an hour.
- fetchFeedAndReactions: stream into a Map, debounce a 150ms
rebuild of _feedCache.events, fire notifyFeedProgress() on each
batch so loadFeedTab re-renders progressively.
- loadFeedTab: register a per-tab rerender closure as
_currentFeedRerender, render whatever is cached immediately,
re-render on each progress tick. _currentFeedRerender === rerender
identity check prevents a late await from clobbering a tab the
user switched away from.
- Add live status line ("Streaming · N templates · R/T relays")
above the feed wall in New and Popular tabs.
- buildRowsFromEvents: respect NIP-09 deletion semantics by tracking
per-address deletion timestamps. Templates published AFTER a kind-5
deletion at the same address are no longer silently filtered.
- Drop wss://relay.nostr.pub from default relays (DNS no longer
resolves).
See docs/STREAMING_RELAY_SYNC_FEATURE.md for the full plan and
rationale.
Co-Authored-By: Claude Haiku 4.5 <[email protected]>
10 KiB
Streaming Relay Sync & Soft Cooldown Plan
Goals
Make the Community template browser feel fast and reliable by rendering Nostr template events progressively as they arrive from each relay, instead of waiting for every relay (or a 4.5s timeout) before showing anything.
Product goals
- First template visible within ~300ms of opening Community (assuming any fast relay is reachable).
- Slow or flaky relays no longer block the feed — their results trickle in late instead of being lost.
- A single transient relay timeout does not silently shrink the relay pool for the next hour.
- No regression in publish reliability or social action (react/follow/repost) flows.
Non-goals (v1)
- Persistent local cache of fetched template events across sessions.
- Relay scoring / per-relay weighting beyond simple cooldown.
- Per-event retry on a different relay if its origin relay drops mid-stream.
- Switching to a Nostr library (e.g. NDK, nostr-tools pool). Stay with the in-file WebSocket implementation.
Problem statement
Symptoms reported: Nostr designs (kind 30078 notes) often never appear in the template browser, or take very long to appear.
Observed in src/index.html:
queryRelays()(line 2980) opens a WebSocket per relay, sends a singleREQ, and collects events into aMap. It usesPromise.allover every relay and only returns the merged result after every relay either finishes (EOSE) or hits the timeout. The default timeout is 3500ms; the template fetch uses 4500ms.fetchFeedAndReactions()(line 5735) awaits that full result before populating_feedCache.events.loadFeedTab()(line 6217) waits for the entire cache to fill before building any rows.markRelayFailure()(line 2685) is called on any timeout, error, or unexpected close, and pushes the relay into a cooldown that starts at 10 minutes and escalates up to 1 hour. State is persisted tolocalStorageundercasewrap-relay-health, so failures bleed across sessions.
Net effect: if one or two relays are slow, the user stares at "Loading…" for the full timeout window, and any transient hiccup quietly removes that relay from the rotation for the rest of the hour — which compounds over time and explains the "sometimes never loads" report.
Current architecture
loadFeedTab(tab)
└── await fetchFeedAndReactions()
├── await Promise.all([
│ queryRelays({ kinds:[30078], limit:200 }, 4500ms), ← blocks until all relays settle
│ queryRelays({ kinds:[5], limit:100 }, 3500ms)
│ ])
├── batch queryRelays({ kinds:[7], '#e':[...] }) ← reactions
└── batch queryRelays({ kinds:[0], authors:[...] }) ← profiles
└── buildRowsFromEvents(_feedCache.events, …)
└── render
Single render at the end. No partial UI.
Proposed architecture
loadFeedTab(tab)
└── show skeleton
└── start fetchFeedAndReactions({ onProgress: rerender })
├── queryRelays({ kinds:[30078] }, { onEvent }) ────────────┐
│ onEvent → push into _feedCache.events Map │
│ trigger debounced onProgress │
│ ├── render fires repeatedly as
├── queryRelays({ kinds:[5] }, { onEvent }) ────────────┘ new templates arrive
├── (await both relay sets to settle)
├── batch reactions → onProgress
└── batch profiles → onProgress
Key shape changes:
queryRelays(relays, filters, { timeoutMs, onEvent, onRelayDone })— new optional callbacks. Promise still resolves with the full event array when all relays settle, so non-streaming call sites are unchanged._feedCache.eventspopulated incrementally via a Map keyed byevent.id(was: written once at the end).fetchFeedAndReactions(opts)accepts{ onProgress }. Calls it (debounced 150ms) whenever the cache changes.loadFeedTab(tab)passes anonProgressthat re-runsbuildRowsFromEventsand re-renders the current tab.
Cooldown change (read vs publish)
markRelayFailure(relay, error, { context }) gets a context param:
'publish'(default, preserves current behavior): base 10 min, max 1 hr.'read': base 30s, max 5 min.
queryRelays passes { context: 'read' }. publishToRelays is unchanged (still defaults to 'publish').
Rationale: a single dropped read should not lock a relay out of the next 10 minutes of browsing. A repeated 30s skip is enough to deprioritize a genuinely-broken relay while keeping the pool wide for transient blips.
Phased delivery plan
Phase 1 — Soft cooldown for reads
Smallest, isolated change. High impact on its own because it stops the relay pool from silently shrinking.
Changes
src/index.htmlconstants block: addRELAY_READ_FAILURE_COOLDOWN_MS = 30 * 1000andRELAY_READ_FAILURE_MAX_COOLDOWN_MS = 5 * 60 * 1000.markRelayFailure(relay, error, opts = {}): acceptcontext, pick cooldown base/max accordingly.queryRelays: pass{ context: 'read' }to everymarkRelayFailurecall inside it.publishToRelays: no change (default'publish'context).
Acceptance
- Triggering a read-side timeout (e.g. by adding a known-dead relay URL) puts the relay in cooldown for ~30s, verifiable via
localStorage['casewrap-relay-health']. - Triggering a publish-side failure still produces a 10+ min cooldown.
Phase 2 — Streaming queryRelays
Non-breaking extension of the existing function.
Changes
- Add
onEvent(event, relay)andonRelayDone({ relay, ok, error })options toqueryRelays. - In the
messagehandler, when a NEWEVENTarrives (not already in the dedupe Map), fireonEventafter inserting. Do not re-fire for duplicates. - In
finish(), fireonRelayDoneonce before resolving. - Wrap callback calls in try/catch so a buggy listener can't kill the relay reader.
- Existing return value (sorted event array, resolves after all relays settle) is preserved.
Acceptance
- Existing call sites (publish verification, profile fetch, reaction batches, mute list, etc.) compile and behave identically.
- A new test invocation that passes
onEventreceives events in arrival order across relays, with eachevent.idfired at most once.
Phase 3 — Progressive feed render
Wire streaming into the template browser.
Changes
- Replace the array shape of
_feedCache.eventswith a Map-backed accumulator. Add a getter helpergetFeedEvents()that returns the merged array (cache + persisted templates) for use bybuildRowsFromEvents. fetchFeedAndReactions({ onProgress }):- Reset the accumulator at start.
- Run the template and deletion queries with
onEventcallbacks that push into the accumulator and schedule a debouncedonProgress(≈150ms). - After both queries settle, fire
onProgressonce more, then run reaction and profile batches (each followed by a finalonProgress). - Continue to gate concurrent calls with
_feedCache.loading.
loadFeedTab(tab):- Render a one-time skeleton.
- Define
rerender()that re-runsbuildRowsFromEvents, sorts, paginates, and replaces the wall contents. Bail out if the user has switched to a different tab. - Call
await fetchFeedAndReactions({ onProgress: rerender }). - One last
rerender()after the await.
- Search and "Mine" tabs reuse the same pattern via a shared helper.
Acceptance
- With all six default relays in the list, first template appears in the wall before the slowest relay has responded.
- Switching tabs mid-stream cancels stale renders (no flicker into the wrong tab).
- Reaction counts and author avatars appear after the initial render without wiping the cards.
- "No templates found…" message only appears after both template-side relays have all settled with zero rows.
Engineering notes
- Debounce window: 150ms is a balance between visible progressiveness and avoiding layout thrash when a fast relay dumps 50 events in one tick.
- Dedupe: must happen on
event.id, not address — multiple relays will return the same event. Existingevents.set(id, …)pattern already does this for the return value; the streaming callback must also gate on "not previously seen" to avoid double-rendering. - Cancellation: if the user switches tabs, the in-flight queries keep running (they're cheap and we want the cache warm), but the
rerendercallback should checkstate.community.currentTaband no-op when stale. - Persisted templates:
loadPersistedPublishedTemplateEvents()should be merged once at the start (so the user's own published designs show even with zero relay results) and again eachonProgress. - Backwards compat: call sites that don't pass
onEventsee no behavior change._feedCache.eventsexposed as an array via the getter keepsbuildRowsFromEventscallers unchanged.
Risks and tradeoffs
| Risk | Mitigation |
|---|---|
| Re-render flicker as events stream in | Debounce + key cards by event.id so the diff is additive, not a full wipe |
| User clicks a card that gets re-sorted under their cursor | Sort key is stable (created_at desc, then id desc); new cards append to the end of the current sort, not above |
| Faster cooldown means a genuinely dead relay gets retried more often | Worst case is a 30s WebSocket open attempt every ~30s per dead relay, which is cheap. Failure count still escalates the cooldown up to 5 min |
| Streaming exposes payload-parse errors more loudly | Parse errors stay quietly skipped inside buildRowsFromEvents; no UI change |
| Long-running WebSockets leak if relay never sends EOSE | Existing per-relay timeoutMs still applies and calls finish() even with streaming |
Out of scope / future enhancements
- Single-shot retry on a different relay when the original relay drops mid-stream.
- Background "warm" subscription that stays open while the modal is closed, so opening Community is instant.
- Per-relay latency surface in the relay settings UI (so users can see which relays are slowing them down).
- Caching template events across reloads with a TTL (would need careful invalidation against kind-5 deletions).