mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
perf(desktop): persist channel heads, collapse thread reads and reply sends (#6572)
## Summary
Lands the build-now items from the desktop latency plan
(#ui-performance-deep-dive) as one change. Every perceived-latency hot
path a user hits on launch, channel open, thread open, and reply send
drops one or more round trips.
**A1 — persisted channel heads (the big one).** Native WAL SQLite cache
(`desktop/src-tauri/src/channel_head_cache.rs`) keyed by `{pubkey,
relayUrl}` scope, 32 rows/scope LRU, 1 MiB per-row drop cap,
schema-version reset, corrupt-row tolerance, checkpointed on shutdown.
Three blocking-pool commands: `channel_head_cache_load` / `_store` /
`_clear`. On the renderer side, `CommunityQueryProvider` kicks off
hydration of up to 12 heads when it constructs the query client — the
app, splash and relay preconnect mount immediately; only
`useChannelMessagesQuery` awaits the seed (`channelHeadHydration`), then
consumes a one-shot hydrated gate so a hydrated channel pays **zero**
`get_channel_window` calls on mount and exactly **one** on the
post-subscription refresh, whose response replaces page zero wholesale.
That refresh fires whether live-subscription setup succeeds or fails,
and is sequenced behind hydration so it is always a distinct
authoritative fetch (see Review follow-ups). Bounds-only persisted heads
(zero rows) are not hydrated and take the cold loading path. The
timeline loading latch recognizes native-hydrated rows as restart-safe
so they paint immediately instead of holding a skeleton. The cache is a
paint accelerator only — the relay response is always authoritative.
Replaces the legacy localStorage `messageSnapshot.ts` (removed, -401
lines).
Kill switch: `VITE_BUZZ_CHANNEL_HEAD_CACHE=off` at build time or
`localStorage["buzz-channel-head-cache"] = "off"` at runtime. Cache is
cleared on community removal and scoped per identity, so a replaced
signer never sees the previous identity's rows.
**B1 — thread aux in one response.** Relay thread filters accept
`include_aux`; the bridge appends the same authorized two-hop
reactions/edits/deletions closure a channel window gets
(`build_aux_query` shared with the window path). Renderer
`useThreadReplies` drops its two follow-up aux fetches. `next_cursor` is
computed from reply-kind rows only since aux rows are unpaged.
Documented in `docs/bridge-channel-window.md`. Thread queries keep
`staleTime: 0` (`bcfe04e2f`): an earlier revision raised it to 30s,
which CI's `thread-unread.spec.ts` caught — once the user leaves a
channel, the live subscription stops feeding that thread's cache, so a
reopen must always take the (now single) authoritative read.
**B2 — cached root on reply send.** `send_channel_message` gains
`root_event_id`; when the renderer already holds the parent (channel or
thread cache) it passes the NIP-10 root, and native signs without the
relay round trip that `resolve_thread_ref` used to make. Strict hex
parse; `root_event_id` requires `parent_event_id`; absent root falls
back to the existing relay resolution. The renderer never sends a
guessed root.
**B4** general HTTP pool idle 10s→300s, max idle per host 1→2. **B5**
relay preconnect fires as soon as identity is ready instead of waiting
for `requestIdleCallback`. One e2e test (`relay-reconnect.spec.ts`
"service restart close resets accumulated backoff") had been relying on
the idle-callback batching to skip past its own seeded dial failures
before the channel list painted; `8133d70bb` makes it wait for the
connected state instead (test-only, still fails with the 1012 backoff
reset disabled). **B6** profile freshness 60s→10 min (both the in-memory
entry check and the query `staleTime`). Tradeoff: another user's
display-name/avatar edit can take up to 10 min to propagate to a client
that already holds their profile (relay reconnect refetches
`users-batch` but resolves from the still-fresh per-pubkey entry); your
own edits still evict the entry immediately (`evictUsersBatchEntries` in
`useUpdateProfileMutation`).
### Related issue
Follows #6456/#6457/#6459/#6460 (already merged). #6455 is the
measurement instrument and is intentionally not folded in. No duplicate
PR found.
### Review follow-ups
Addressing Carl's reviews
[5001114109](https://github.com/block/buzz/pull/6572#pullrequestreview-5001114109)
and
[5002596542](https://github.com/block/buzz/pull/6572#pullrequestreview-5002596542),
each pushed as new commits (no rebase):
- `4f06b7770` fix(desktop): mount app while channel heads hydrate;
always revalidate — provider no longer gates children on the cache load;
`refreshAfterSubscribe` runs on subscribe failure too; bounds-only heads
skipped at seed; seed merges into an existing window store. +3 tests.
- `35834cb31` fix(relay): drain aux closure hops across the page clamp —
`query_all_pages` walks the `(created_at, id)` keyset via
`until`/`before_id` until a short page (`AUX_PAGE_LIMIT` =
`DEFAULT_MAX_PAGE_LIMIT`, `AUX_MAX_PAGES` = 64 warn+truncate) so
one-shot `limit: 1000` newest-first no longer drops the oldest
edits/deletions. +3 tests; `docs/bridge-channel-window.md` updated.
- `db21b0531` merge of `origin/main` `e23632941` (#6558, #6312 — no
overlap).
- `5a5566c0f` fix(desktop): sequence post-subscribe refresh behind
channel head hydration — `refreshChannelWindowMessages` awaits
`channelHeadHydration()` and, for a hydration-seeded query (`data !==
undefined && dataUpdatedAt === 0`), the in-flight snapshot fetch before
invalidating. Without this, a subscription that settles before the
SQLite load invalidated a data-less in-flight query; TanStack dedupes
that onto the existing fetch (`query-core` `fetch()` only cancels when
`state.data` exists), which returned the seeded snapshot — 0
authoritative fetches. Regression test reproduces Carl's exact ordering
(fails at `35834cb31` with 0 calls), plus a cold-channel guard that the
fix does not double-fetch.
- `b129231c8` fix(desktop): let concurrent post-hydration refreshes
share one window fetch — found independently by Max and Wren reviewing
`5a5566c0f`: subscribe settlement + reconnect both wake on the same
snapshot promise and both invalidate; the second (default
`cancelRefetch: true`) cancelled and replaced the first authoritative
fetch (3 queryFn calls, not 2, and the cancelled Tauri invoke still hits
the relay). The seeded branch now invalidates with `cancelRefetch:
false` so a second waker joins the in-flight fetch; cold/warm keep the
default (`test_canceled_stale_fetch_cannot_overwrite_catch_up_window`
relies on it). Concurrent regression test fails at `5a5566c0f` with 3.
### Testing
At `b129231c8` (PR head; verified in one shell with `git rev-parse HEAD`
= `b129231c8`): `pnpm check`, `tsc --noEmit`, desktop unit 5,393 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.
At `5a5566c0f`: `pnpm check`, `tsc --noEmit`, desktop unit 5,392 / 0,
Playwright `boot-splash` + `channel-head-restart` + `relay-reconnect` +
`relay-reconnect-affordance` + `thread-unread` 34 / 34 on a fresh
`build:e2e`, pre-push hooks green.
At `35834cb31`: desktop unit 5,390 / 0; `cargo test -p buzz-relay --lib`
910 / 0; fmt + clippy `-D warnings` clean; Playwright 32 / 32 (same
specs minus affordance); GitHub CI green on every job except Smoke (3)
(unrelated project-review row-count + messaging timing flake, per Carl)
and Unit Tests (sherpa cache skeleton, below).
Earlier, all at `8133d70bb` (this PR head is `0c492366d` = 8133d70bb + a
comments-only commit correcting two `profile/hooks.ts` freshness
comments from 60s to 10 min; pre-push desktop check/typecheck/test
5,387/0 re-ran at 0c492366d) in one shell; `origin/main` = `040b203f7`
at PR open, since moved to `4baccd539` (#6558, mobile only — zero file
overlap, `git merge-tree` clean):
- `just desktop-test` — 5,387 passed / 0 failed (includes new hook-level
call-count test: cold = 1, stale-prefetched = 1, hydrated = 0 on mount
then 1 on invalidate with wholesale replacement)
- Playwright smoke `relay-reconnect.spec.ts` + `thread-unread.spec.ts` +
`channel-head-restart.spec.ts` — 30/30 (thread-unread was 8/13 at
`7acbf951b`; relay-reconnect was 15/16 at `bcfe04e2f`). The restart spec
persists a head, reloads into a fresh mock relay with the head fetch
held 5s, asserts the persisted row paints within 2s, exactly one
`get_channel_window` after open, and the stale row is removed when the
authoritative page lands.
- `pnpm typecheck`, `pnpm check` — clean
At `7acbf951b` (everything except the two-line `useThreadReplies.ts`
staleTime revert and the test-only `relay-reconnect.spec.ts` change),
also green in one shell:
- `just desktop-tauri-test` — 2,859 passed / 0 failed across the
workspace (channel_head_cache: wire shape, LRU+caps, schema reset,
corrupt-row skip)
- `just test-unit` — 632 passed (buzz-core/auth); `cargo test -p
buzz-relay --lib` — 908 passed / 0 failed
- `just check` components: fmt-check, clippy, desktop-check,
desktop-typecheck, desktop-tauri-fmt-check, desktop-tauri-clippy,
web-check, mobile-check, file-size-check — all green
- `just desktop-build`, `web-build`, `desktop-tauri-check`,
`mobile-test` (1,661 passed) — all green
CI note: the "Unit Tests" job goes red on this PR and on `main` whenever
it hits a poisoned `rust-cache` entry (an empty-directory skeleton of
`target/sherpa-onnx-prebuilt` that `sherpa-onnx-sys` build.rs trusts),
surfacing as `could not find native static library sherpa-onnx-c-api` in
`buzz-voice` — a crate this PR doesn't touch. Deleting the cache entry
and rerunning turned the job green at `0c492366d` (28/28); it re-poisons
on the next `main` push until the workflow clears that directory after
cache restore.
Reviewed in-channel by Wren (9 / 9 / 9.5) and Eva (9 / 9 / 9), and
line-by-line by me before opening; the staleTime fix re-verified by Wren
and me independently; the relay-reconnect test fix bisected and verified
by me.
---------
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -393,6 +393,83 @@ const WINDOW_AUX_DELETE_KINDS: [u32; 2] = [
|
||||
buzz_core::kind::KIND_NIP29_DELETE_EVENT,
|
||||
];
|
||||
|
||||
/// Page size for one aux-closure hop. Matches the DB clamp
|
||||
/// (`buzz_db::DEFAULT_MAX_PAGE_LIMIT`) so each page is one full query.
|
||||
const AUX_PAGE_LIMIT: i64 = buzz_db::DEFAULT_MAX_PAGE_LIMIT;
|
||||
/// Upper bound on pages drained per hop: 64k aux events referencing one page
|
||||
/// of rows is far past any real thread; past it we log and stop rather than
|
||||
/// loop forever against a pathological write pattern.
|
||||
const AUX_MAX_PAGES: usize = 64;
|
||||
|
||||
fn build_aux_query(
|
||||
community: buzz_core::CommunityId,
|
||||
target_ids: Vec<String>,
|
||||
kinds: &[u32],
|
||||
) -> buzz_db::EventQuery {
|
||||
let mut query = buzz_db::EventQuery::for_community(community);
|
||||
query.kinds = Some(kinds.iter().map(|kind| *kind as i32).collect());
|
||||
query.e_tags = Some(target_ids);
|
||||
query
|
||||
}
|
||||
|
||||
/// Where an aux hop reads from: the window path pins the request's proved
|
||||
/// read session; the thread path takes the routed display-read fast path.
|
||||
enum AuxReader<'a> {
|
||||
Session(&'a mut buzz_db::ReadSession),
|
||||
Routed(&'a buzz_db::Db, &'static str),
|
||||
#[cfg(test)]
|
||||
Fake(&'a mut (dyn FnMut(&buzz_db::EventQuery) -> Vec<buzz_core::StoredEvent> + Send)),
|
||||
}
|
||||
|
||||
impl AuxReader<'_> {
|
||||
async fn fetch(
|
||||
&mut self,
|
||||
query: &buzz_db::EventQuery,
|
||||
) -> buzz_db::Result<Vec<buzz_core::StoredEvent>> {
|
||||
match self {
|
||||
AuxReader::Session(session) => session.query_events(query).await,
|
||||
AuxReader::Routed(db, path) => db.query_events_routed(path, query).await,
|
||||
#[cfg(test)]
|
||||
AuxReader::Fake(fetch) => Ok(fetch(query)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain every event matching `query`, walking the `(created_at, id)` keyset
|
||||
/// cursor `query_events` already orders by until a short page. An aux hop
|
||||
/// over a reaction-heavy page can exceed a single page clamp, and because
|
||||
/// results are newest-first a one-shot query silently drops the *oldest*
|
||||
/// edits and deletions — rendering original or deleted content, not merely
|
||||
/// losing decoration.
|
||||
async fn query_all_pages(
|
||||
mut query: buzz_db::EventQuery,
|
||||
page_limit: i64,
|
||||
reader: &mut AuxReader<'_>,
|
||||
) -> buzz_db::Result<Vec<buzz_core::StoredEvent>> {
|
||||
query.limit = Some(page_limit);
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..AUX_MAX_PAGES {
|
||||
let page = reader.fetch(&query).await?;
|
||||
let next = if page.len() as i64 >= page_limit {
|
||||
page.last().map(|se| (se.event.created_at, se.event.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
events.extend(page);
|
||||
let Some((created_at, id)) = next else {
|
||||
return Ok(events);
|
||||
};
|
||||
query.until = chrono::DateTime::from_timestamp(created_at.as_secs() as i64, 0);
|
||||
query.before_id = Some(id.to_bytes().to_vec());
|
||||
}
|
||||
tracing::warn!(
|
||||
pages = AUX_MAX_PAGES,
|
||||
events = events.len(),
|
||||
"aux closure hop exceeded page cap; returning truncated closure"
|
||||
);
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
/// Serve one `top_level: true` channel-window filter on the bridge `/query`
|
||||
/// path (docs/bridge-channel-window.md). Appends, in order: row events, the
|
||||
/// aux closure (`include_aux`), `39005` thread-summary overlays
|
||||
@@ -496,14 +573,15 @@ async fn handle_channel_window_filter(
|
||||
std::collections::HashSet::new();
|
||||
let mut hop_ids = row_ids_hex.clone();
|
||||
for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] {
|
||||
let mut aux_query = buzz_db::EventQuery::for_community(tenant.community());
|
||||
aux_query.kinds = Some(hop_kinds.iter().map(|k| *k as i32).collect());
|
||||
aux_query.e_tags = Some(std::mem::take(&mut hop_ids));
|
||||
aux_query.limit = Some(1000);
|
||||
let aux_events = session
|
||||
.query_events(&aux_query)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("window aux error: {e}")))?;
|
||||
let aux_query =
|
||||
build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds);
|
||||
let aux_events = query_all_pages(
|
||||
aux_query,
|
||||
AUX_PAGE_LIMIT,
|
||||
&mut AuxReader::Session(&mut session),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("window aux error: {e}")))?;
|
||||
for se in aux_events {
|
||||
if !seen_aux.insert(se.event.id) {
|
||||
continue;
|
||||
@@ -1203,6 +1281,8 @@ async fn query_events_authed(
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("thread query error: {e}")))?;
|
||||
|
||||
let mut thread_row_ids = Vec::with_capacity(thread_replies.len() + 1);
|
||||
thread_row_ids.push(root_hex.to_string());
|
||||
for reply in thread_replies {
|
||||
let se = reply.stored_event;
|
||||
if !event_in_accessible_channel(&se, &accessible_channels) {
|
||||
@@ -1214,10 +1294,45 @@ async fn query_events_authed(
|
||||
if !buzz_core::filter::reader_authorized_for_event(&se.event, &authed_pubkey_hex) {
|
||||
continue;
|
||||
}
|
||||
thread_row_ids.push(se.event.id.to_hex());
|
||||
if let Ok(v) = serde_json::to_value(&se.event) {
|
||||
events.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
if extension_flag(raw, "include_aux") && !thread_row_ids.is_empty() {
|
||||
let mut seen_aux = std::collections::HashSet::new();
|
||||
let mut hop_ids = thread_row_ids;
|
||||
for hop_kinds in [&WINDOW_AUX_KINDS[..], &WINDOW_AUX_DELETE_KINDS[..]] {
|
||||
let aux_query =
|
||||
build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds);
|
||||
let aux_events = query_all_pages(
|
||||
aux_query,
|
||||
AUX_PAGE_LIMIT,
|
||||
&mut AuxReader::Routed(&state.db, "bridge_thread_aux"),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| internal_error(&format!("thread aux query error: {e}")))?;
|
||||
for se in aux_events {
|
||||
if !seen_aux.insert(se.event.id)
|
||||
|| !event_in_accessible_channel(&se, &accessible_channels)
|
||||
|| !buzz_core::filter::reader_authorized_for_event(
|
||||
&se.event,
|
||||
&authed_pubkey_hex,
|
||||
)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
hop_ids.push(se.event.id.to_hex());
|
||||
if let Ok(value) = serde_json::to_value(&se.event) {
|
||||
events.push(value);
|
||||
}
|
||||
}
|
||||
if hop_ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
handled.insert(idx);
|
||||
}
|
||||
|
||||
@@ -2373,6 +2488,113 @@ mod tests {
|
||||
assert!(!has_mixed_search_filters(&filters));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_aux_query_targets_root_and_replies() {
|
||||
let tenant = fresh_tenant("relay.example");
|
||||
let targets = vec!["root".to_string(), "reply".to_string()];
|
||||
let query = build_aux_query(tenant.community(), targets.clone(), &WINDOW_AUX_KINDS);
|
||||
|
||||
assert_eq!(query.e_tags, Some(targets));
|
||||
assert_eq!(
|
||||
query.kinds,
|
||||
Some(WINDOW_AUX_KINDS.iter().map(|kind| *kind as i32).collect())
|
||||
);
|
||||
assert_eq!(query.limit, None);
|
||||
assert_eq!(query.until, None);
|
||||
assert_eq!(query.before_id, None);
|
||||
}
|
||||
|
||||
fn aux_event(keys: &Keys, created_at: u64, content: &str) -> buzz_core::StoredEvent {
|
||||
let ev = EventBuilder::new(Kind::Custom(7), content)
|
||||
.custom_created_at(nostr::Timestamp::from(created_at))
|
||||
.sign_with_keys(keys)
|
||||
.unwrap();
|
||||
buzz_core::StoredEvent::new(ev, None)
|
||||
}
|
||||
|
||||
/// Carl/#6572: a one-shot `limit=1000` aux query is newest-first, so the
|
||||
/// oldest reactions/edits/deletions past the clamp vanished. The paged
|
||||
/// drain must walk the keyset cursor until a short page and return every
|
||||
/// event exactly once.
|
||||
#[tokio::test]
|
||||
async fn query_all_pages_drains_past_the_page_clamp() {
|
||||
let keys = Keys::generate();
|
||||
// Newest-first store: 5 events, two sharing a second so the id
|
||||
// tiebreak is exercised.
|
||||
let mut store = [
|
||||
aux_event(&keys, 50, "e"),
|
||||
aux_event(&keys, 40, "d1"),
|
||||
aux_event(&keys, 40, "d2"),
|
||||
aux_event(&keys, 30, "c"),
|
||||
aux_event(&keys, 10, "a"),
|
||||
];
|
||||
store.sort_by(|l, r| {
|
||||
r.event
|
||||
.created_at
|
||||
.cmp(&l.event.created_at)
|
||||
.then(l.event.id.cmp(&r.event.id))
|
||||
});
|
||||
let expected: Vec<_> = store.iter().map(|se| se.event.id).collect();
|
||||
let mut calls = Vec::new();
|
||||
|
||||
let tenant = fresh_tenant("relay.example");
|
||||
let query = build_aux_query(tenant.community(), vec!["root".into()], &WINDOW_AUX_KINDS);
|
||||
let mut fetch = |q: &buzz_db::EventQuery| {
|
||||
calls.push((q.limit, q.until, q.before_id.clone()));
|
||||
// Emulate `query_events_on`: `created_at < until OR
|
||||
// (created_at = until AND id > before_id)`, newest-first, limit.
|
||||
let page: Vec<_> = store
|
||||
.iter()
|
||||
.filter(|se| match (q.until, q.before_id.as_deref()) {
|
||||
(Some(until), Some(before)) => {
|
||||
let ts = se.event.created_at.as_secs() as i64;
|
||||
ts < until.timestamp()
|
||||
|| (ts == until.timestamp()
|
||||
&& se.event.id.as_bytes().as_slice() > before)
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
.take(q.limit.unwrap() as usize)
|
||||
.cloned()
|
||||
.collect();
|
||||
page
|
||||
};
|
||||
let events = query_all_pages(query, 2, &mut AuxReader::Fake(&mut fetch))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
events.iter().map(|se| se.event.id).collect::<Vec<_>>(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(calls.len(), 3, "2 full pages + 1 short page");
|
||||
assert!(calls.iter().all(|(limit, _, _)| *limit == Some(2)));
|
||||
assert_eq!(calls[0].1, None);
|
||||
// Second page resumes from the last row of the first (ts 40, larger id).
|
||||
assert_eq!(calls[1].1.unwrap().timestamp(), 40);
|
||||
assert_eq!(
|
||||
calls[1].2.as_deref(),
|
||||
Some(store[1].event.id.as_bytes().as_slice())
|
||||
);
|
||||
assert_eq!(calls[2].1.unwrap().timestamp(), 30);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_all_pages_stops_at_one_short_page() {
|
||||
let tenant = fresh_tenant("relay.example");
|
||||
let query = build_aux_query(tenant.community(), vec!["root".into()], &WINDOW_AUX_KINDS);
|
||||
let mut calls = 0;
|
||||
let mut fetch = |_q: &buzz_db::EventQuery| {
|
||||
calls += 1;
|
||||
Vec::new()
|
||||
};
|
||||
let events = query_all_pages(query, 1000, &mut AuxReader::Fake(&mut fetch))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(events.is_empty());
|
||||
assert_eq!(calls, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_search_mode_extension_defaults_to_full_text() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -99,6 +99,7 @@ export default defineConfig({
|
||||
"**/scroll-history.spec.ts",
|
||||
"**/channel-dense-second-reach.spec.ts",
|
||||
"**/channel-window-mock-paging.spec.ts",
|
||||
"**/channel-head-restart.spec.ts",
|
||||
"**/live-broadcast-reply-timeline.spec.ts",
|
||||
"**/markdown-parse-cache.spec.ts",
|
||||
"**/overscroll-boundary.spec.ts",
|
||||
|
||||
@@ -194,8 +194,8 @@ pub fn build_app_state() -> AppState {
|
||||
identity_storage: AtomicU8::new(identity_storage as u8),
|
||||
http_client: reqwest::Client::builder()
|
||||
.resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0)))
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(10))
|
||||
.pool_max_idle_per_host(1)
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(2)
|
||||
.build()
|
||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
||||
media_fetch_client: build_media_fetch_client().expect(
|
||||
|
||||
432
desktop/src-tauri/src/channel_head_cache.rs
Normal file
432
desktop/src-tauri/src/channel_head_cache.rs
Normal file
@@ -0,0 +1,432 @@
|
||||
//! Persistent native cache for recently visited channel head pages.
|
||||
//!
|
||||
//! The cache is a paint accelerator only: the renderer always replaces a
|
||||
//! hydrated page with an authoritative relay response after subscribing.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 1;
|
||||
const CHANNELS_PER_SCOPE_CAP: i64 = 32;
|
||||
const ROW_BYTES_CAP: usize = 1024 * 1024;
|
||||
|
||||
/// Serializes cache mutations on the blocking pool.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ChannelHeadCacheStore {
|
||||
write_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ChannelHeadScope {
|
||||
pub(crate) pubkey: String,
|
||||
pub(crate) relay_url: String,
|
||||
}
|
||||
|
||||
impl ChannelHeadScope {
|
||||
fn key(&self) -> String {
|
||||
format!(
|
||||
"{}:{}",
|
||||
self.pubkey.trim().to_ascii_lowercase(),
|
||||
self.relay_url.trim().trim_end_matches('/')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ChannelHeadEntry {
|
||||
channel_id: String,
|
||||
events: Vec<Value>,
|
||||
saved_at: i64,
|
||||
last_visited_at: i64,
|
||||
}
|
||||
|
||||
fn db_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|error| format!("resolve channel-head cache data dir: {error}"))?;
|
||||
std::fs::create_dir_all(&dir)
|
||||
.map_err(|error| format!("create channel-head cache data dir: {error}"))?;
|
||||
Ok(dir.join("channel-head-cache.db"))
|
||||
}
|
||||
|
||||
fn create_schema(conn: &Connection) -> Result<(), String> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE schema_meta(version INTEGER NOT NULL);
|
||||
INSERT INTO schema_meta(version) VALUES(1);
|
||||
CREATE TABLE channel_head(
|
||||
scope TEXT NOT NULL,
|
||||
channel_id TEXT NOT NULL,
|
||||
events_json TEXT NOT NULL,
|
||||
row_count INTEGER NOT NULL,
|
||||
saved_at INTEGER NOT NULL,
|
||||
last_visited_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(scope, channel_id)
|
||||
);",
|
||||
)
|
||||
.map_err(|error| format!("initialize channel-head cache db: {error}"))
|
||||
}
|
||||
|
||||
fn open_db(path: &Path) -> Result<Connection, String> {
|
||||
let conn =
|
||||
Connection::open(path).map_err(|error| format!("open channel-head cache db: {error}"))?;
|
||||
conn.pragma_update(None, "busy_timeout", 5_000)
|
||||
.map_err(|error| format!("configure channel-head cache db: {error}"))?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")
|
||||
.map_err(|error| format!("configure channel-head cache WAL: {error}"))?;
|
||||
|
||||
let has_schema_meta: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_meta')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("inspect channel-head cache schema: {error}"))?;
|
||||
if !has_schema_meta {
|
||||
create_schema(&conn)?;
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
let version = conn
|
||||
.query_row("SELECT version FROM schema_meta LIMIT 1", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.optional()
|
||||
.map_err(|error| format!("read channel-head cache schema: {error}"))?;
|
||||
let has_channel_head: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='channel_head')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("inspect channel-head cache table: {error}"))?;
|
||||
if version != Some(SCHEMA_VERSION) || !has_channel_head {
|
||||
conn.execute_batch("DROP TABLE IF EXISTS channel_head; DROP TABLE IF EXISTS schema_meta;")
|
||||
.map_err(|error| format!("reset channel-head cache schema: {error}"))?;
|
||||
create_schema(&conn)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
async fn run_blocking<T, F>(task: F) -> Result<T, String>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Result<T, String> + Send + 'static,
|
||||
{
|
||||
tauri::async_runtime::spawn_blocking(task)
|
||||
.await
|
||||
.map_err(|error| format!("channel-head cache db task failed: {error}"))?
|
||||
}
|
||||
|
||||
fn load_from_path(
|
||||
path: &Path,
|
||||
scope: &ChannelHeadScope,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ChannelHeadEntry>, String> {
|
||||
let conn = open_db(path)?;
|
||||
let mut statement = conn
|
||||
.prepare(
|
||||
"SELECT channel_id, events_json, saved_at, last_visited_at
|
||||
FROM channel_head WHERE scope=?1
|
||||
ORDER BY last_visited_at DESC, saved_at DESC, channel_id ASC LIMIT ?2",
|
||||
)
|
||||
.map_err(|error| format!("prepare channel-head cache load: {error}"))?;
|
||||
let rows = statement
|
||||
.query_map(params![scope.key(), i64::from(limit)], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)?,
|
||||
row.get::<_, i64>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|error| format!("query channel-head cache: {error}"))?;
|
||||
let mut entries = Vec::new();
|
||||
for row in rows {
|
||||
let (channel_id, events_json, saved_at, last_visited_at) =
|
||||
row.map_err(|error| format!("read channel-head cache row: {error}"))?;
|
||||
let events = match serde_json::from_str(&events_json) {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
eprintln!("skipping corrupt channel-head cache row {channel_id}: {error}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
entries.push(ChannelHeadEntry {
|
||||
channel_id,
|
||||
events,
|
||||
saved_at,
|
||||
last_visited_at,
|
||||
});
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn store_in_transaction(
|
||||
transaction: &Transaction<'_>,
|
||||
scope: &str,
|
||||
channel_id: &str,
|
||||
events_json: &str,
|
||||
row_count: usize,
|
||||
now: i64,
|
||||
) -> Result<(), String> {
|
||||
let last_visited_at: i64 = transaction
|
||||
.query_row(
|
||||
"SELECT COALESCE(MAX(last_visited_at), ?2 - 1) + 1 FROM channel_head WHERE scope=?1",
|
||||
params![scope, now],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|error| format!("advance channel-head cache visit clock: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO channel_head(scope, channel_id, events_json, row_count, saved_at, last_visited_at)
|
||||
VALUES(?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(scope, channel_id) DO UPDATE SET
|
||||
events_json=excluded.events_json,
|
||||
row_count=excluded.row_count,
|
||||
saved_at=excluded.saved_at,
|
||||
last_visited_at=excluded.last_visited_at",
|
||||
params![scope, channel_id, events_json, row_count as i64, now, last_visited_at],
|
||||
)
|
||||
.map_err(|error| format!("store channel-head cache row: {error}"))?;
|
||||
transaction
|
||||
.execute(
|
||||
"DELETE FROM channel_head WHERE rowid IN (
|
||||
SELECT rowid FROM channel_head WHERE scope=?1
|
||||
ORDER BY last_visited_at DESC, saved_at DESC, channel_id ASC
|
||||
LIMIT -1 OFFSET ?2
|
||||
)",
|
||||
params![scope, CHANNELS_PER_SCOPE_CAP],
|
||||
)
|
||||
.map_err(|error| format!("prune channel-head cache: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_at(
|
||||
path: &Path,
|
||||
scope: &ChannelHeadScope,
|
||||
channel_id: &str,
|
||||
events: &[Value],
|
||||
now: i64,
|
||||
) -> Result<(), String> {
|
||||
let events_json = serde_json::to_string(events)
|
||||
.map_err(|error| format!("encode channel-head cache row: {error}"))?;
|
||||
let mut conn = open_db(path)?;
|
||||
let transaction = conn
|
||||
.transaction()
|
||||
.map_err(|error| format!("begin channel-head cache store: {error}"))?;
|
||||
if events_json.len() > ROW_BYTES_CAP {
|
||||
transaction
|
||||
.execute(
|
||||
"DELETE FROM channel_head WHERE scope=?1 AND channel_id=?2",
|
||||
params![scope.key(), channel_id],
|
||||
)
|
||||
.map_err(|error| format!("drop oversized channel-head cache row: {error}"))?;
|
||||
} else {
|
||||
store_in_transaction(
|
||||
&transaction,
|
||||
&scope.key(),
|
||||
channel_id,
|
||||
&events_json,
|
||||
events.len(),
|
||||
now,
|
||||
)?;
|
||||
}
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|error| format!("commit channel-head cache store: {error}"))
|
||||
}
|
||||
|
||||
/// Loads the most recently visited channel heads for one identity and relay.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn channel_head_cache_load(
|
||||
scope: ChannelHeadScope,
|
||||
limit: u32,
|
||||
app: AppHandle,
|
||||
) -> Result<Vec<ChannelHeadEntry>, String> {
|
||||
let path = db_path(&app)?;
|
||||
run_blocking(move || load_from_path(&path, &scope, limit)).await
|
||||
}
|
||||
|
||||
/// Stores one raw channel-window response, dropping payloads above one MiB.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn channel_head_cache_store(
|
||||
scope: ChannelHeadScope,
|
||||
channel_id: String,
|
||||
events: Vec<Value>,
|
||||
app: AppHandle,
|
||||
store: State<'_, ChannelHeadCacheStore>,
|
||||
) -> Result<(), String> {
|
||||
let path = db_path(&app)?;
|
||||
let write_lock = Arc::clone(&store.write_lock);
|
||||
run_blocking(move || {
|
||||
let _guard = write_lock.lock().map_err(|error| error.to_string())?;
|
||||
store_at(
|
||||
&path,
|
||||
&scope,
|
||||
&channel_id,
|
||||
&events,
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clears all persisted channel heads for one identity and relay.
|
||||
#[tauri::command]
|
||||
pub(crate) async fn channel_head_cache_clear(
|
||||
scope: ChannelHeadScope,
|
||||
app: AppHandle,
|
||||
store: State<'_, ChannelHeadCacheStore>,
|
||||
) -> Result<(), String> {
|
||||
let path = db_path(&app)?;
|
||||
let write_lock = Arc::clone(&store.write_lock);
|
||||
run_blocking(move || {
|
||||
let _guard = write_lock.lock().map_err(|error| error.to_string())?;
|
||||
let conn = open_db(&path)?;
|
||||
conn.execute("DELETE FROM channel_head WHERE scope=?1", [scope.key()])
|
||||
.map_err(|error| format!("clear channel-head cache scope: {error}"))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn flush(app: &AppHandle) {
|
||||
if let Ok(path) = db_path(app) {
|
||||
if let Ok(conn) = open_db(&path) {
|
||||
let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE);");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scope() -> ChannelHeadScope {
|
||||
ChannelHeadScope {
|
||||
pubkey: "PK".into(),
|
||||
relay_url: "wss://relay/".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_entry_matches_typescript_contract() {
|
||||
let actual = serde_json::to_value(ChannelHeadEntry {
|
||||
channel_id: "general".into(),
|
||||
events: vec![serde_json::json!({"id":"event"})],
|
||||
saved_at: 42,
|
||||
last_visited_at: 43,
|
||||
})
|
||||
.unwrap();
|
||||
let expected = serde_json::json!({
|
||||
"channelId":"general",
|
||||
"events":[{"id":"event"}],
|
||||
"savedAt":42,
|
||||
"lastVisitedAt":43
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
let decoded: ChannelHeadScope = serde_json::from_value(serde_json::json!({
|
||||
"pubkey":"PK", "relayUrl":"wss://relay/"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(decoded, scope());
|
||||
assert_eq!(decoded.key(), "pk:wss://relay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_lru_and_payload_caps() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("channel-head-cache.db");
|
||||
for index in 0..=CHANNELS_PER_SCOPE_CAP {
|
||||
store_at(
|
||||
&path,
|
||||
&scope(),
|
||||
&format!("channel-{index:02}"),
|
||||
&[serde_json::json!({"index":index})],
|
||||
1_000 + index,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let entries = load_from_path(&path, &scope(), 100).unwrap();
|
||||
assert_eq!(entries.len(), CHANNELS_PER_SCOPE_CAP as usize);
|
||||
assert_eq!(entries.first().unwrap().channel_id, "channel-32");
|
||||
assert!(!entries.iter().any(|entry| entry.channel_id == "channel-00"));
|
||||
|
||||
let oversized = vec![Value::String("x".repeat(ROW_BYTES_CAP))];
|
||||
store_at(&path, &scope(), "channel-32", &oversized, 2_000).unwrap();
|
||||
let count: i64 = open_db(&path)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM channel_head WHERE channel_id='channel-32'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_corrupt_rows_without_blanketing_good_entries() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("channel-head-cache.db");
|
||||
store_at(
|
||||
&path,
|
||||
&scope(),
|
||||
"good-channel",
|
||||
&[serde_json::json!({"id":"good-event"})],
|
||||
1_000,
|
||||
)
|
||||
.unwrap();
|
||||
let conn = open_db(&path).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO channel_head VALUES(?1, 'bad-channel', 'not-json', 1, 1001, 1001)",
|
||||
[scope().key()],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let entries = load_from_path(&path, &scope(), 12).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].channel_id, "good-channel");
|
||||
assert_eq!(
|
||||
entries[0].events,
|
||||
vec![serde_json::json!({"id":"good-event"})]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_mismatch_recreates_cache() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("channel-head-cache.db");
|
||||
let conn = open_db(&path).unwrap();
|
||||
conn.execute("UPDATE schema_meta SET version=99", [])
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO channel_head VALUES('scope','channel','[]',0,1,1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let reset = open_db(&path).unwrap();
|
||||
let version: i64 = reset
|
||||
.query_row("SELECT version FROM schema_meta", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
let rows: i64 = reset
|
||||
.query_row("SELECT COUNT(*) FROM channel_head", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, SCHEMA_VERSION);
|
||||
assert_eq!(rows, 0);
|
||||
}
|
||||
}
|
||||
@@ -236,22 +236,11 @@ fn search_messages_limit(limit: Option<u32>) -> u32 {
|
||||
limit.unwrap_or(20).min(500)
|
||||
}
|
||||
|
||||
/// Fetch the full reply subtree under a thread root, server-side.
|
||||
///
|
||||
/// Unlike the channel timeline (which the desktop assembles from its local
|
||||
/// cache by grouping on `e`-root tags), this walks `thread_metadata` on the
|
||||
/// relay via `get_thread_replies`, so a thread renders complete even when its
|
||||
/// replies fell outside the channel cold-load window. Results are chronological
|
||||
/// (oldest first) and are the *replies* under the root (depth >= 1); the root
|
||||
/// event itself is NOT returned (the relay query keys on `root_event_id`, and a
|
||||
/// root row has no `root_event_id`). Callers already hold the root — it is the
|
||||
/// open thread head — so this closes the descendant gap without re-fetching it.
|
||||
/// Fetch the reply subtree and its auxiliary events under a thread root.
|
||||
///
|
||||
/// Paging is forward keyset on `(created_at, event_id)`: pass the `next_cursor`
|
||||
/// from a previous page back as `cursor` to fetch the next batch. The event-id
|
||||
/// tiebreak is required because replies routinely share a `created_at` second;
|
||||
/// a timestamp-only cursor would skip every tied reply past the page limit.
|
||||
/// `next_cursor` is `Some` only when a full page was returned.
|
||||
/// tiebreak prevents same-second replies from being skipped.
|
||||
#[tauri::command]
|
||||
pub async fn get_thread_replies(
|
||||
root_event_id: String,
|
||||
@@ -275,8 +264,12 @@ pub async fn get_thread_replies(
|
||||
// A full page implies there may be more; hand back the last event's
|
||||
// composite key as the next cursor (the DB returns replies strictly after
|
||||
// it, tiebroken by event_id so same-second replies are not skipped).
|
||||
let next_cursor = if events.len() as u32 >= cap {
|
||||
events.last().map(|ev| crate::models::ThreadCursor {
|
||||
let reply_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|event| TIMELINE_KINDS.contains(&(event.kind.as_u16() as u32)))
|
||||
.collect();
|
||||
let next_cursor = if reply_events.len() as u32 >= cap {
|
||||
reply_events.last().map(|ev| crate::models::ThreadCursor {
|
||||
created_at: ev.created_at.as_secs() as i64,
|
||||
event_id: ev.id.to_hex(),
|
||||
})
|
||||
@@ -295,21 +288,9 @@ pub async fn get_thread_replies(
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the relay `/query` filter for the server-side thread-subtree read.
|
||||
///
|
||||
/// The relay routes a filter to `get_thread_replies` purely off a single `#e`
|
||||
/// (root) tag plus `depth_limit` — kind is NOT part of that routing or the
|
||||
/// underlying DB query (it keys on `root_event_id`). Yet `kinds` is still
|
||||
/// required here: the bridge runs the p-gate (`p_gated_filters_authorized`) on
|
||||
/// every filter *before* routing, and a kindless filter "could match" a p-gated
|
||||
/// kind, so the gate demands a `#p` tag we don't send -> HTTP 403
|
||||
/// `restricted: p-gated kinds require #p tag`, before the thread query ever
|
||||
/// runs. Carrying non-p-gated [`TIMELINE_KINDS`] makes the filter provably
|
||||
/// un-p-gated so it clears the gate. `build_channel_messages_before_filter` is
|
||||
/// the sibling that already does this, which is why the dense-second channel
|
||||
/// pager was never gated and this reader was. Extracted so a unit test can pin
|
||||
/// that `kinds` is present (the e2e mock does not model p-gating, so only a
|
||||
/// unit test guards this contract).
|
||||
/// Build the relay `/query` filter for a thread-subtree read.
|
||||
/// `kinds` is required to prove the filter cannot match p-gated events; without
|
||||
/// it, relay authorization rejects this otherwise kindless query.
|
||||
fn build_thread_replies_filter(
|
||||
root_event_id: &str,
|
||||
channel_id: Option<&str>,
|
||||
@@ -324,6 +305,7 @@ fn build_thread_replies_filter(
|
||||
// defaults it to a deep-but-bounded value so nested replies aren't dropped.
|
||||
filter.insert("depth_limit".to_string(), serde_json::json!(depth_limit));
|
||||
filter.insert("limit".to_string(), serde_json::json!(cap));
|
||||
filter.insert("include_aux".to_string(), serde_json::json!(true));
|
||||
if let Some(cid) = channel_id {
|
||||
filter.insert("#h".to_string(), serde_json::json!([cid]));
|
||||
}
|
||||
@@ -435,7 +417,7 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<S
|
||||
// ── Writes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
mod thread_ref;
|
||||
use thread_ref::resolve_thread_ref;
|
||||
use thread_ref::{resolve_thread_ref, thread_ref};
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -443,6 +425,7 @@ pub async fn send_channel_message(
|
||||
channel_id: String,
|
||||
content: String,
|
||||
parent_event_id: Option<String>,
|
||||
root_event_id: Option<String>,
|
||||
media_tags: Option<Vec<Vec<String>>>,
|
||||
emoji_tags: Option<Vec<Vec<String>>>,
|
||||
mention_tags: Option<Vec<Vec<String>>>,
|
||||
@@ -483,6 +466,9 @@ pub async fn send_channel_message(
|
||||
if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE {
|
||||
return Err("sent-from-thread provenance requires a stream message".into());
|
||||
}
|
||||
if root_event_id.is_some() && parent_event_id.is_none() {
|
||||
return Err("root_event_id requires parent_event_id".into());
|
||||
}
|
||||
|
||||
let mut resolved_root: Option<String> = None;
|
||||
|
||||
@@ -498,8 +484,14 @@ pub async fn send_channel_message(
|
||||
let parent_id = parent_event_id
|
||||
.as_deref()
|
||||
.ok_or("forum comment requires parent_event_id")?;
|
||||
let thread_ref =
|
||||
resolve_thread_ref(parent_id, &state, &relay_base, Some(&signing_keys)).await?;
|
||||
let thread_ref = thread_ref(
|
||||
parent_id,
|
||||
root_event_id.as_deref(),
|
||||
&state,
|
||||
&relay_base,
|
||||
Some(&signing_keys),
|
||||
)
|
||||
.await?;
|
||||
resolved_root = Some(thread_ref.root_event_id.to_hex());
|
||||
events::build_forum_comment(
|
||||
channel_uuid,
|
||||
@@ -513,8 +505,14 @@ pub async fn send_channel_message(
|
||||
_ => {
|
||||
let thread_ref = match parent_event_id.as_deref() {
|
||||
Some(pid) => {
|
||||
let tr =
|
||||
resolve_thread_ref(pid, &state, &relay_base, Some(&signing_keys)).await?;
|
||||
let tr = thread_ref(
|
||||
pid,
|
||||
root_event_id.as_deref(),
|
||||
&state,
|
||||
&relay_base,
|
||||
Some(&signing_keys),
|
||||
)
|
||||
.await?;
|
||||
resolved_root = Some(tr.root_event_id.to_hex());
|
||||
Some(tr)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use nostr::EventId;
|
||||
use nostr::{EventId, Keys};
|
||||
|
||||
use crate::{
|
||||
app_state::AppState,
|
||||
@@ -6,6 +6,38 @@ use crate::{
|
||||
relay::{query_relay_at, query_relay_at_with_keys},
|
||||
};
|
||||
|
||||
/// Build a thread reference from a renderer-supplied root and parent.
|
||||
///
|
||||
/// Both IDs are parsed before signing. This path intentionally performs no
|
||||
/// relay query: the renderer supplies a root only when the parent is already
|
||||
/// present in its cache and the root can be read from that event's NIP-10 tags.
|
||||
pub(super) fn provided_thread_ref(
|
||||
root_event_id: &str,
|
||||
parent_event_id: &str,
|
||||
) -> Result<events::ThreadRef, String> {
|
||||
let root_event_id =
|
||||
EventId::from_hex(root_event_id).map_err(|e| format!("invalid root event ID: {e}"))?;
|
||||
let parent_event_id =
|
||||
EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?;
|
||||
Ok(events::ThreadRef {
|
||||
root_event_id,
|
||||
parent_event_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn thread_ref(
|
||||
parent_event_id: &str,
|
||||
root_event_id: Option<&str>,
|
||||
state: &AppState,
|
||||
api_base_url: &str,
|
||||
signing_keys: Option<&Keys>,
|
||||
) -> Result<events::ThreadRef, String> {
|
||||
match root_event_id {
|
||||
Some(root_event_id) => provided_thread_ref(root_event_id, parent_event_id),
|
||||
None => resolve_thread_ref(parent_event_id, state, api_base_url, signing_keys).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch a parent event and extract the thread root from its NIP-10 e-tags.
|
||||
///
|
||||
/// Reads through the explicit `api_base_url` the calling command resolved —
|
||||
|
||||
@@ -171,6 +171,7 @@ fn thread_replies_filter_carries_non_p_gated_kinds_to_clear_the_gate() {
|
||||
assert_eq!(filter["#e"], serde_json::json!(["root-hex"]));
|
||||
assert_eq!(filter["depth_limit"], serde_json::json!(64));
|
||||
assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
|
||||
assert_eq!(filter["include_aux"], serde_json::json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -224,3 +225,14 @@ fn legacy_managed_agent_auth_tag_skips_self_attestation() {
|
||||
|
||||
assert_eq!(tag, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provided_thread_ref_validates_and_preserves_root_and_parent() {
|
||||
let root = "11".repeat(32);
|
||||
let parent = "22".repeat(32);
|
||||
let thread_ref = thread_ref::provided_thread_ref(&root, &parent)
|
||||
.expect("valid 64-hex event ids should be accepted");
|
||||
assert_eq!(thread_ref.root_event_id.to_hex(), root);
|
||||
assert_eq!(thread_ref.parent_event_id.to_hex(), parent);
|
||||
assert!(thread_ref::provided_thread_ref("not-hex", &parent).is_err());
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod app_menu;
|
||||
mod app_state;
|
||||
mod archive;
|
||||
mod builderlab;
|
||||
mod channel_head_cache;
|
||||
mod commands;
|
||||
mod deep_link;
|
||||
mod egress_guard;
|
||||
@@ -234,6 +235,7 @@ pub fn run() {
|
||||
.manage(archive::sync::ArchiveSyncState::default())
|
||||
.manage(native_relay_client::NativeRelayClient::default())
|
||||
.manage(observed_unread::ObservedUnreadStore::default())
|
||||
.manage(channel_head_cache::ChannelHeadCacheStore::default())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -724,6 +726,9 @@ pub fn run() {
|
||||
unread_catch_up::unread_catch_up,
|
||||
observed_unread::observed_unread_open_scope,
|
||||
observed_unread::observed_unread_ingest,
|
||||
channel_head_cache::channel_head_cache_load,
|
||||
channel_head_cache::channel_head_cache_store,
|
||||
channel_head_cache::channel_head_cache_clear,
|
||||
list_personas,
|
||||
create_persona,
|
||||
update_persona,
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a
|
||||
if !shutdown_done.swap(true, Ordering::SeqCst) {
|
||||
prevent_sleep::release(&app.state::<AppState>().prevent_sleep);
|
||||
crate::observed_unread::flush(app);
|
||||
crate::channel_head_cache::flush(app);
|
||||
app.state::<crate::terminal_runtime::TerminalSessions>()
|
||||
.shutdown_all();
|
||||
if let Err(error) = shutdown_managed_agents(app) {
|
||||
|
||||
@@ -63,6 +63,7 @@ import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChang
|
||||
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
|
||||
import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
|
||||
import { createBuzzQueryClient } from "@/shared/api/queryClient";
|
||||
import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
|
||||
import { getProfile } from "@/shared/api/tauriProfiles";
|
||||
@@ -213,8 +214,30 @@ function CommunitySwitchGate() {
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityQueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(createBuzzQueryClient);
|
||||
function CommunityQueryProvider({
|
||||
children,
|
||||
pubkey,
|
||||
relayUrl,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pubkey: string | null;
|
||||
relayUrl: string | null;
|
||||
}) {
|
||||
// Seeding persisted channel heads is part of constructing the client, not a
|
||||
// gate in front of the app: the splash, AppReady, and relay preconnect mount
|
||||
// immediately, and only the channel query waits on the cache load (see
|
||||
// channelHeadHydration). It must start here rather than in an effect —
|
||||
// React Query fires a child's queryFn when it subscribes, before any parent
|
||||
// effect runs — and StrictMode's dev-only double initializer just issues one
|
||||
// redundant read on a discarded client. The provider is keyed on the
|
||||
// community, so one client maps to one {pubkey, relayUrl} scope.
|
||||
const [queryClient] = useState(() => {
|
||||
const client = createBuzzQueryClient();
|
||||
if (pubkey && relayUrl) {
|
||||
void hydrateChannelHeads(client, { pubkey, relayUrl });
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]);
|
||||
|
||||
@@ -601,7 +624,11 @@ function CommunityApp({
|
||||
}, [communityApplied]);
|
||||
if (appContent === null && (!transaction || isEnteringCurtain)) {
|
||||
appContent = communityApplied ? (
|
||||
<CommunityQueryProvider key={communityKey}>
|
||||
<CommunityQueryProvider
|
||||
key={communityKey}
|
||||
pubkey={community.identityPubkey}
|
||||
relayUrl={activeCommunity?.relayUrl ?? null}
|
||||
>
|
||||
<CommunityIdentityReplacementSentinel
|
||||
onIdentityReplaced={bumpSignerEpoch}
|
||||
/>
|
||||
|
||||
@@ -42,33 +42,13 @@ export function useAppShellLifecycleEffects({
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const startPreconnect = () => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
void relayClient.preconnect().catch((error) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to preconnect to relay", error);
|
||||
}
|
||||
|
||||
void relayClient.preconnect().catch((error) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to preconnect to relay", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
const idleId = window.requestIdleCallback(startPreconnect, {
|
||||
timeout: 1_500,
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.cancelIdleCallback(idleId);
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = globalThis.setTimeout(startPreconnect, 250);
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAppShell } from "@/app/AppShellContext";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
|
||||
@@ -45,6 +46,7 @@ import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMen
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache";
|
||||
import {
|
||||
resolveTimelineLoadingLatch,
|
||||
selectTimelineLoadingState,
|
||||
@@ -95,6 +97,7 @@ export function ChannelScreen({
|
||||
targetMessageEvents,
|
||||
targetMessageId,
|
||||
}: ChannelScreenProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { goHome } = useAppNavigation();
|
||||
const { activeCommunity } = useCommunities();
|
||||
const {
|
||||
@@ -607,7 +610,12 @@ export function ChannelScreen({
|
||||
isPlaceholderData: messagesQuery.isPlaceholderData,
|
||||
dataLength: messagesQuery.data?.length ?? null,
|
||||
},
|
||||
hasSettledThisChannel,
|
||||
// A persisted head only counts as hydrated when it has rows to paint
|
||||
// (channelHeadCache.ts), so this bypass never settles onto an empty
|
||||
// placeholder while the authoritative refresh is still in flight.
|
||||
hasSettledThisChannel ||
|
||||
(activeChannelId !== null &&
|
||||
hasPersistedHydratedChannel(queryClient, activeChannelId)),
|
||||
);
|
||||
const { settledChannelId, isLoading: isTimelineLoading } =
|
||||
resolveTimelineLoadingLatch(
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
|
||||
import { removeUserLabelCacheForRelay } from "@/features/profile/lib/userLabelStorage";
|
||||
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
|
||||
import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot";
|
||||
import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache";
|
||||
import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
|
||||
import {
|
||||
clearCommunityDestinations,
|
||||
@@ -234,7 +235,16 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
removeSelfProfileCachesForRelay(removed.relayUrl);
|
||||
removeUserLabelCacheForRelay(removed.relayUrl);
|
||||
removeChannelSnapshotForRelay(removed.relayUrl);
|
||||
removeMessageSnapshotsForRelay(removed.relayUrl);
|
||||
void getIdentity()
|
||||
.then((identity) =>
|
||||
clearChannelHeadCache({
|
||||
pubkey: identity.pubkey,
|
||||
relayUrl: removed.relayUrl,
|
||||
}),
|
||||
)
|
||||
.catch((error) => {
|
||||
console.warn("Failed to clear persisted channel heads", error);
|
||||
});
|
||||
clearSavedCommunitySnapshot(id);
|
||||
removeCommunityDestination(id);
|
||||
|
||||
|
||||
@@ -84,7 +84,12 @@ async function resetCommunityState({
|
||||
}
|
||||
|
||||
type CommunityInitResult =
|
||||
| { isReady: true; needsSetup: false; appliedKey: string }
|
||||
| {
|
||||
isReady: true;
|
||||
needsSetup: false;
|
||||
appliedKey: string;
|
||||
identityPubkey: string | null;
|
||||
}
|
||||
| {
|
||||
isReady: false;
|
||||
needsSetup: true;
|
||||
@@ -342,6 +347,7 @@ export function useCommunityInit(
|
||||
isReady: true,
|
||||
needsSetup: false,
|
||||
appliedKey: communityKey,
|
||||
identityPubkey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
refreshChannelWindowMessages,
|
||||
} from "@/features/messages/lib/projectChannelWindow";
|
||||
import { reconcileChannelWindowMessages } from "@/features/messages/lib/channelWindowReconciliation";
|
||||
import {
|
||||
channelHeadCacheScope,
|
||||
channelHeadHydration,
|
||||
consumeHydratedChannel,
|
||||
} from "@/features/messages/lib/channelHeadCache";
|
||||
import { storeChannelHeadCache } from "@/shared/api/tauriChannelHeadCache";
|
||||
import {
|
||||
mergeMessages,
|
||||
mergeTimelineCacheMessages,
|
||||
@@ -85,6 +91,18 @@ type MessageQueryContext = {
|
||||
const CHANNEL_TIMELINE_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);
|
||||
const CHANNEL_AUX_KINDS = new Set<number>(CHANNEL_AUX_EVENT_KINDS);
|
||||
|
||||
export function resolveCachedReplyRootId(
|
||||
parentEventId: string,
|
||||
messageCaches: readonly RelayEvent[][],
|
||||
): string | null {
|
||||
for (const messages of messageCaches) {
|
||||
if (messages.some((event) => event.id === parentEventId)) {
|
||||
return resolveReplyRootId(parentEventId, messages);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createOptimisticMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
@@ -257,18 +275,30 @@ export function reconcileFetchedChannelWindow(
|
||||
emptyChannelWindowStore();
|
||||
const next = replaceNewestChannelWindow(current, page);
|
||||
queryClient.setQueryData(windowKey, next);
|
||||
const scope = channelHeadCacheScope(queryClient);
|
||||
if (scope) {
|
||||
void storeChannelHeadCache(scope, channelId, events).catch((error) => {
|
||||
console.warn("Failed to persist channel head", channelId, error);
|
||||
});
|
||||
}
|
||||
return reconcileChannelWindowMessages(next, previousMessages);
|
||||
}
|
||||
|
||||
export function useChannelMessagesQuery(channel: Channel | null) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = channelMessagesKey(channel?.id ?? "none");
|
||||
|
||||
return useQuery({
|
||||
enabled: channel !== null && channel.channelType !== "forum",
|
||||
queryKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!channel) throw new Error("No channel selected.");
|
||||
// Persisted heads seed asynchronously; wait for that seed so a channel
|
||||
// opened during boot takes the hydrated path instead of racing it with
|
||||
// a cold relay fetch.
|
||||
await channelHeadHydration(queryClient);
|
||||
if (consumeHydratedChannel(queryClient, channel.id)) {
|
||||
return queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
}
|
||||
const previousMessages =
|
||||
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const events = await getChannelWindowEvents(channel.id);
|
||||
@@ -393,36 +423,45 @@ export function useChannelSubscription(channel: Channel | null) {
|
||||
});
|
||||
});
|
||||
|
||||
// The live subscription starts at "now", so it cannot close the gap
|
||||
// between the last page snapshot and subscription establishment. Always
|
||||
// refresh once subscription setup settles — on success because freshness
|
||||
// alone is not proof that no relay events landed in that interval, and on
|
||||
// failure because a hydrated channel has no other authoritative fetch:
|
||||
// the relay window endpoint may be healthy even when the live socket is
|
||||
// not, and the reconnect listener above re-syncs when it recovers.
|
||||
const refreshAfterSubscribe = (outcome: string) => {
|
||||
if (isDisposed) return;
|
||||
void refreshNewestWindow().catch((error) => {
|
||||
if (!isDisposed) {
|
||||
console.error(
|
||||
`Failed to refresh channel window after ${outcome}`,
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
relayClient
|
||||
.subscribeToChannelLive(channelId, (event) => {
|
||||
if (!isDisposed) {
|
||||
appendMessage(event);
|
||||
}
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup = dispose;
|
||||
// The live subscription starts at "now", so it cannot close the gap
|
||||
// between the last page snapshot and subscription establishment. Always
|
||||
// refresh after the subscription is active; freshness alone is not a
|
||||
// proof that no relay events landed in that interval.
|
||||
void refreshNewestWindow().catch((error) => {
|
||||
if (!isDisposed) {
|
||||
console.error(
|
||||
"Failed to refresh channel window after subscribing",
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
.then(
|
||||
(dispose) => {
|
||||
if (isDisposed) {
|
||||
void dispose();
|
||||
return;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to subscribe to channel", channelId, error);
|
||||
});
|
||||
cleanup = dispose;
|
||||
refreshAfterSubscribe("subscribing");
|
||||
},
|
||||
(error) => {
|
||||
console.error("Failed to subscribe to channel", channelId, error);
|
||||
refreshAfterSubscribe("subscription failure");
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
@@ -538,6 +577,17 @@ export function useSendMessageMutation(
|
||||
queryClient.getQueryData<RelayEvent[]>(
|
||||
channelMessagesKey(effectiveChannel.id),
|
||||
) ?? [];
|
||||
const threadCaches = queryClient
|
||||
.getQueriesData<RelayEvent[]>({
|
||||
queryKey: ["thread-replies", effectiveChannel.id],
|
||||
})
|
||||
.flatMap(([, events]) => (events ? [events] : []));
|
||||
const suppliedRootEventId = parentEventId
|
||||
? resolveCachedReplyRootId(parentEventId, [
|
||||
cachedMessages,
|
||||
...threadCaches,
|
||||
])
|
||||
: null;
|
||||
const result = await sendChannelMessage(
|
||||
effectiveChannel.id,
|
||||
content,
|
||||
@@ -549,6 +599,9 @@ export function useSendMessageMutation(
|
||||
mentionTags,
|
||||
linkPreviewTags,
|
||||
sentFromThreadTag,
|
||||
undefined,
|
||||
undefined,
|
||||
suppliedRootEventId,
|
||||
);
|
||||
|
||||
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji.
|
||||
@@ -559,7 +612,7 @@ export function useSendMessageMutation(
|
||||
effectiveChannel.id,
|
||||
identity.pubkey,
|
||||
parentEventId,
|
||||
resolveReplyRootId(parentEventId, cachedMessages),
|
||||
result.rootEventId ?? parentEventId,
|
||||
recipientPubkeys,
|
||||
)
|
||||
: [];
|
||||
|
||||
382
desktop/src/features/messages/lib/channelHeadCache.test.mjs
Normal file
382
desktop/src/features/messages/lib/channelHeadCache.test.mjs
Normal file
@@ -0,0 +1,382 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, mock, test } from "node:test";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import {
|
||||
consumeHydratedChannel,
|
||||
hasPersistedHydratedChannel,
|
||||
hydrateChannelHeads,
|
||||
} from "./channelHeadCache.ts";
|
||||
import { channelMessagesKey } from "./messageQueryKeys.ts";
|
||||
import {
|
||||
reconcileFetchedChannelWindow,
|
||||
useChannelMessagesQuery,
|
||||
useChannelSubscription,
|
||||
} from "../hooks.ts";
|
||||
import { relayClient } from "../../../shared/api/relayClient.ts";
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
const channel = {
|
||||
id: "channel-a",
|
||||
name: "general",
|
||||
channelType: "stream",
|
||||
visibility: "open",
|
||||
description: "",
|
||||
topic: null,
|
||||
purpose: null,
|
||||
memberCount: 1,
|
||||
memberPubkeys: [],
|
||||
lastMessageAt: null,
|
||||
archivedAt: null,
|
||||
participants: [],
|
||||
participantPubkeys: [],
|
||||
isMember: true,
|
||||
ttlSeconds: null,
|
||||
ttlDeadline: null,
|
||||
};
|
||||
let channelWindowCalls = 0;
|
||||
let channelWindowEvents = [];
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
const channelId = "channel-a";
|
||||
const root = {
|
||||
id: "a".repeat(64),
|
||||
pubkey: "b".repeat(64),
|
||||
created_at: 10,
|
||||
kind: 40002,
|
||||
tags: [["h", channelId]],
|
||||
content: "persisted",
|
||||
sig: "",
|
||||
};
|
||||
const replacement = {
|
||||
...root,
|
||||
id: "c".repeat(64),
|
||||
created_at: 11,
|
||||
content: "relay",
|
||||
};
|
||||
function bounds() {
|
||||
return {
|
||||
id: "d".repeat(64),
|
||||
pubkey: "e".repeat(64),
|
||||
created_at: 12,
|
||||
kind: 39006,
|
||||
tags: [
|
||||
["h", channelId],
|
||||
["d", `${channelId}:head`],
|
||||
],
|
||||
content: JSON.stringify({ has_more: false, next_cursor: null }),
|
||||
sig: "",
|
||||
};
|
||||
}
|
||||
function install(entries, { loadDelayMs = 0 } = {}) {
|
||||
channelWindowCalls = 0;
|
||||
channelWindowEvents = [replacement, bounds()];
|
||||
window.localStorage.clear();
|
||||
window.__TAURI_INTERNALS__ = {
|
||||
invoke: async (command) => {
|
||||
if (command === "channel_head_cache_load") {
|
||||
if (loadDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, loadDelayMs));
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
if (command === "get_channel_window") {
|
||||
channelWindowCalls += 1;
|
||||
return channelWindowEvents;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function mountChannelQuery(client) {
|
||||
const { renderHook, waitFor } = await import("@testing-library/react");
|
||||
const view = renderHook(() => useChannelMessagesQuery(channel), {
|
||||
wrapper: ({ children }) =>
|
||||
React.createElement(QueryClientProvider, { client }, children),
|
||||
});
|
||||
await waitFor(() => assert.equal(view.result.current.isSuccess, true));
|
||||
return view;
|
||||
}
|
||||
test("mount fetches cold and prefetched channels but consumes hydrated data", async () => {
|
||||
install([]);
|
||||
const coldClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const coldView = await mountChannelQuery(coldClient);
|
||||
assert.equal(channelWindowCalls, 1);
|
||||
|
||||
install([]);
|
||||
const prefetchedClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
prefetchedClient.setQueryData(channelMessagesKey(channelId), [root], {
|
||||
updatedAt: 0,
|
||||
});
|
||||
const prefetchedView = await mountChannelQuery(prefetchedClient);
|
||||
assert.equal(channelWindowCalls, 1);
|
||||
|
||||
install([
|
||||
{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 },
|
||||
]);
|
||||
const hydratedClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
await hydrateChannelHeads(hydratedClient, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
const hydratedView = await mountChannelQuery(hydratedClient);
|
||||
assert.equal(channelWindowCalls, 0);
|
||||
assert.deepEqual(hydratedView.result.current.data, [root]);
|
||||
|
||||
await hydratedClient.invalidateQueries({
|
||||
queryKey: channelMessagesKey(channelId),
|
||||
exact: true,
|
||||
refetchType: "active",
|
||||
});
|
||||
assert.equal(channelWindowCalls, 1);
|
||||
assert.deepEqual(hydratedClient.getQueryData(channelMessagesKey(channelId)), [
|
||||
replacement,
|
||||
]);
|
||||
hydratedView.unmount();
|
||||
coldView.unmount();
|
||||
prefetchedView.unmount();
|
||||
coldClient.clear();
|
||||
prefetchedClient.clear();
|
||||
hydratedClient.clear();
|
||||
});
|
||||
|
||||
test("hydrates stale data and consumes its mount gate once", async () => {
|
||||
install([
|
||||
{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 },
|
||||
]);
|
||||
const client = new QueryClient();
|
||||
await hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
assert.deepEqual(client.getQueryData(channelMessagesKey(channelId)), [root]);
|
||||
assert.equal(
|
||||
client.getQueryState(channelMessagesKey(channelId)).dataUpdatedAt,
|
||||
0,
|
||||
);
|
||||
assert.equal(consumeHydratedChannel(client, channelId), true);
|
||||
assert.equal(consumeHydratedChannel(client, channelId), false);
|
||||
});
|
||||
test("authoritative refresh deletes a vanished hydrated row", async () => {
|
||||
install([
|
||||
{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 },
|
||||
]);
|
||||
const client = new QueryClient();
|
||||
await hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
const next = reconcileFetchedChannelWindow(
|
||||
client,
|
||||
channelId,
|
||||
[replacement, bounds()],
|
||||
client.getQueryData(channelMessagesKey(channelId)),
|
||||
new AbortController().signal,
|
||||
);
|
||||
assert.deepEqual(
|
||||
next.map((e) => e.id),
|
||||
[replacement.id],
|
||||
);
|
||||
});
|
||||
test("drops malformed entries independently", async () => {
|
||||
install([
|
||||
{ channelId: "bad", events: [root], savedAt: 1, lastVisitedAt: 2 },
|
||||
{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 },
|
||||
]);
|
||||
const client = new QueryClient();
|
||||
await hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
assert.equal(client.getQueryData(channelMessagesKey("bad")), undefined);
|
||||
assert.deepEqual(client.getQueryData(channelMessagesKey(channelId)), [root]);
|
||||
});
|
||||
|
||||
test("a channel mounted during a slow cache load still takes the hydrated path", async () => {
|
||||
// Carl/#6572 (1): the app no longer waits for the cache before mounting, so
|
||||
// the channel query must itself wait for the seed instead of racing it with
|
||||
// a cold relay fetch.
|
||||
install(
|
||||
[{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }],
|
||||
{ loadDelayMs: 150 },
|
||||
);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
void hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
const view = await mountChannelQuery(client);
|
||||
assert.equal(channelWindowCalls, 0);
|
||||
assert.deepEqual(view.result.current.data, [root]);
|
||||
view.unmount();
|
||||
client.clear();
|
||||
});
|
||||
test("a bounds-only persisted head is not hydrated", async () => {
|
||||
// Carl/#6572 (3): zero rows paint nothing, so the channel must take the
|
||||
// cold path and hold its skeleton rather than flash the empty-channel intro.
|
||||
install([{ channelId, events: [bounds()], savedAt: 1, lastVisitedAt: 1 }]);
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
await hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
assert.equal(client.getQueryData(channelMessagesKey(channelId)), undefined);
|
||||
assert.equal(hasPersistedHydratedChannel(client, channelId), false);
|
||||
const view = await mountChannelQuery(client);
|
||||
assert.equal(channelWindowCalls, 1);
|
||||
assert.deepEqual(view.result.current.data, [replacement]);
|
||||
view.unmount();
|
||||
client.clear();
|
||||
});
|
||||
|
||||
test("a hydrated channel still revalidates when live subscription setup fails", async () => {
|
||||
// Carl/#6572 (2): the hydrated path skips get_channel_window on mount, so
|
||||
// the post-subscribe refresh is its only authoritative fetch. A rejected
|
||||
// subscribe must trigger it too, or the channel stays stale all session.
|
||||
install([
|
||||
{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 },
|
||||
]);
|
||||
mock.method(relayClient, "subscribeToReconnects", () => () => {});
|
||||
mock.method(relayClient, "subscribeToChannelLive", () =>
|
||||
Promise.reject(new Error("socket down")),
|
||||
);
|
||||
const consoleError = mock.method(console, "error", () => {});
|
||||
try {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
await hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
const { renderHook, waitFor } = await import("@testing-library/react");
|
||||
const view = renderHook(
|
||||
() => {
|
||||
useChannelSubscription(channel);
|
||||
return useChannelMessagesQuery(channel);
|
||||
},
|
||||
{
|
||||
wrapper: ({ children }) =>
|
||||
React.createElement(QueryClientProvider, { client }, children),
|
||||
},
|
||||
);
|
||||
await waitFor(() => assert.equal(channelWindowCalls, 1));
|
||||
await waitFor(() =>
|
||||
assert.deepEqual(view.result.current.data, [replacement]),
|
||||
);
|
||||
view.unmount();
|
||||
client.clear();
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
assert.ok(
|
||||
consoleError.mock.calls.some(
|
||||
(call) => call.arguments[0] === "Failed to subscribe to channel",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("a live subscription that settles before a slow cache load still revalidates", async () => {
|
||||
// Carl/#6572 re-review: the query is parked on the hydration gate with no
|
||||
// data yet, so an invalidation issued now dedupes onto that in-flight fetch
|
||||
// (cancelRefetch only cancels when data exists). The seed then lands and the
|
||||
// queryFn returns the persisted snapshot — zero authoritative fetches.
|
||||
install(
|
||||
[{ channelId, events: [root, bounds()], savedAt: 1, lastVisitedAt: 1 }],
|
||||
{ loadDelayMs: 150 },
|
||||
);
|
||||
mock.method(relayClient, "subscribeToReconnects", () => () => {});
|
||||
mock.method(relayClient, "subscribeToChannelLive", () =>
|
||||
Promise.resolve(async () => {}),
|
||||
);
|
||||
try {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
void hydrateChannelHeads(client, {
|
||||
pubkey: "f".repeat(64),
|
||||
relayUrl: "wss://relay",
|
||||
});
|
||||
const { renderHook, waitFor } = await import("@testing-library/react");
|
||||
const view = renderHook(
|
||||
() => {
|
||||
useChannelSubscription(channel);
|
||||
return useChannelMessagesQuery(channel);
|
||||
},
|
||||
{
|
||||
wrapper: ({ children }) =>
|
||||
React.createElement(QueryClientProvider, { client }, children),
|
||||
},
|
||||
);
|
||||
await waitFor(() => assert.equal(channelWindowCalls, 1));
|
||||
await waitFor(() =>
|
||||
assert.deepEqual(view.result.current.data, [replacement]),
|
||||
);
|
||||
view.unmount();
|
||||
client.clear();
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
|
||||
test("a cold channel with an immediate live subscription fetches the window once", async () => {
|
||||
// The post-subscribe refresh must still dedupe onto a cold relay fetch that
|
||||
// is already in flight; only the hydration-parked fetch needs sequencing.
|
||||
install([]);
|
||||
mock.method(relayClient, "subscribeToReconnects", () => () => {});
|
||||
mock.method(relayClient, "subscribeToChannelLive", () =>
|
||||
Promise.resolve(async () => {}),
|
||||
);
|
||||
try {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const { renderHook, waitFor } = await import("@testing-library/react");
|
||||
const view = renderHook(
|
||||
() => {
|
||||
useChannelSubscription(channel);
|
||||
return useChannelMessagesQuery(channel);
|
||||
},
|
||||
{
|
||||
wrapper: ({ children }) =>
|
||||
React.createElement(QueryClientProvider, { client }, children),
|
||||
},
|
||||
);
|
||||
await waitFor(() =>
|
||||
assert.deepEqual(view.result.current.data, [replacement]),
|
||||
);
|
||||
await waitFor(() => assert.equal(view.result.current.isFetching, false));
|
||||
assert.equal(channelWindowCalls, 1);
|
||||
view.unmount();
|
||||
client.clear();
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
});
|
||||
110
desktop/src/features/messages/lib/channelHeadCache.ts
Normal file
110
desktop/src/features/messages/lib/channelHeadCache.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
loadChannelHeadCache,
|
||||
type ChannelHeadScope,
|
||||
} from "@/shared/api/tauriChannelHeadCache";
|
||||
import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys";
|
||||
import { parseChannelWindowResponse } from "./channelWindowResponse";
|
||||
import {
|
||||
type ChannelWindowStore,
|
||||
emptyChannelWindowStore,
|
||||
replaceNewestChannelWindow,
|
||||
} from "./channelWindowStore";
|
||||
import { reconcileChannelWindowMessages } from "./channelWindowReconciliation";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
const hydrations = new WeakMap<QueryClient, Promise<void>>();
|
||||
const hydratedChannels = new WeakMap<QueryClient, Set<string>>();
|
||||
const persistedHydratedChannels = new WeakMap<QueryClient, Set<string>>();
|
||||
const cacheScopes = new WeakMap<QueryClient, ChannelHeadScope>();
|
||||
export function isChannelHeadCacheEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
if (import.meta.env?.VITE_BUZZ_CHANNEL_HEAD_CACHE === "off") return false;
|
||||
return window.localStorage.getItem("buzz-channel-head-cache") !== "off";
|
||||
}
|
||||
export function channelHeadCacheScope(
|
||||
queryClient: QueryClient,
|
||||
): ChannelHeadScope | null {
|
||||
return cacheScopes.get(queryClient) ?? null;
|
||||
}
|
||||
/**
|
||||
* Resolves once persisted heads have been seeded into this client (or
|
||||
* immediately when no hydration was started). The channel query awaits this so
|
||||
* it never races the cache load with a cold relay fetch, while the rest of the
|
||||
* app mounts without waiting on the optional paint cache.
|
||||
*/
|
||||
export function channelHeadHydration(queryClient: QueryClient): Promise<void> {
|
||||
return hydrations.get(queryClient) ?? Promise.resolve();
|
||||
}
|
||||
export function consumeHydratedChannel(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
): boolean {
|
||||
const channels = hydratedChannels.get(queryClient);
|
||||
if (!channels?.delete(channelId)) return false;
|
||||
if (channels.size === 0) hydratedChannels.delete(queryClient);
|
||||
return true;
|
||||
}
|
||||
export function hasPersistedHydratedChannel(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
): boolean {
|
||||
return persistedHydratedChannels.get(queryClient)?.has(channelId) ?? false;
|
||||
}
|
||||
export function hydrateChannelHeads(
|
||||
queryClient: QueryClient,
|
||||
scope: ChannelHeadScope,
|
||||
): Promise<void> {
|
||||
const hydration = seedChannelHeads(queryClient, scope).catch((error) => {
|
||||
console.warn("Failed to hydrate persisted channel heads", error);
|
||||
});
|
||||
hydrations.set(queryClient, hydration);
|
||||
return hydration;
|
||||
}
|
||||
async function seedChannelHeads(
|
||||
queryClient: QueryClient,
|
||||
scope: ChannelHeadScope,
|
||||
): Promise<void> {
|
||||
if (!isChannelHeadCacheEnabled()) return;
|
||||
cacheScopes.set(queryClient, scope);
|
||||
const entries = await loadChannelHeadCache(scope, 12);
|
||||
const hydrated = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const page = parseChannelWindowResponse(
|
||||
entry.events,
|
||||
entry.channelId,
|
||||
null,
|
||||
);
|
||||
// A bounds-only head has nothing to paint; let it take the cold path so
|
||||
// the skeleton holds until the relay answers instead of flashing the
|
||||
// empty-channel intro over rows that are still revalidating.
|
||||
if (page.rows.length === 0) continue;
|
||||
const windowKey = channelWindowKey(entry.channelId);
|
||||
const messagesKey = channelMessagesKey(entry.channelId);
|
||||
// Merge, don't replace: the app mounts while this load is in flight, so
|
||||
// the live subscription may already have overlaid events on this store.
|
||||
const window = replaceNewestChannelWindow(
|
||||
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
|
||||
emptyChannelWindowStore(),
|
||||
page,
|
||||
);
|
||||
const messages = reconcileChannelWindowMessages(
|
||||
window,
|
||||
queryClient.getQueryData<RelayEvent[]>(messagesKey) ?? [],
|
||||
);
|
||||
queryClient.setQueryData(windowKey, window, { updatedAt: 0 });
|
||||
queryClient.setQueryData(messagesKey, messages, { updatedAt: 0 });
|
||||
hydrated.add(entry.channelId);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Ignoring invalid persisted channel head",
|
||||
entry.channelId,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hydrated.size > 0) {
|
||||
hydratedChannels.set(queryClient, hydrated);
|
||||
persistedHydratedChannels.set(queryClient, new Set(hydrated));
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
mergeHistoryOverSnapshot,
|
||||
messageSnapshotKey,
|
||||
readMessageSnapshot,
|
||||
removeMessageSnapshotsForRelay,
|
||||
writeMessageSnapshot,
|
||||
} from "./messageSnapshot.ts";
|
||||
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
const storage = new Map();
|
||||
globalThis.window = {
|
||||
localStorage: {
|
||||
getItem: (key) => storage.get(key) ?? null,
|
||||
setItem: (key, value) => storage.set(key, value),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
key: (index) => [...storage.keys()][index] ?? null,
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEvent(overrides = {}) {
|
||||
return {
|
||||
id: `event-${Math.random().toString(36).slice(2)}`,
|
||||
pubkey: "pubkey-1",
|
||||
created_at: 1_700_000_000,
|
||||
kind: 9,
|
||||
tags: [["h", "chan-1"]],
|
||||
content: "hello",
|
||||
sig: "sig",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const RELAY = "wss://relay.example.com";
|
||||
|
||||
function clearRelay(relayUrl = RELAY) {
|
||||
removeMessageSnapshotsForRelay(relayUrl);
|
||||
}
|
||||
|
||||
test("messageSnapshotKey: normalizes trailing slash and case", () => {
|
||||
assert.equal(
|
||||
messageSnapshotKey("WSS://Relay.Example.com/", "chan-1"),
|
||||
messageSnapshotKey("wss://relay.example.com", "chan-1"),
|
||||
);
|
||||
});
|
||||
|
||||
test("read after write returns the persisted events", () => {
|
||||
clearRelay();
|
||||
const events = [makeEvent({ id: "a" }), makeEvent({ id: "b" })];
|
||||
writeMessageSnapshot(RELAY, "chan-1", events);
|
||||
assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), events);
|
||||
});
|
||||
|
||||
test("read for an unknown channel returns null", () => {
|
||||
clearRelay();
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-never"), null);
|
||||
});
|
||||
|
||||
test("read returns null for malformed JSON", () => {
|
||||
window.localStorage.setItem(
|
||||
messageSnapshotKey(RELAY, "chan-bad"),
|
||||
"not-json{{{",
|
||||
);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-bad"), null);
|
||||
});
|
||||
|
||||
test("read returns null for a wrong-version payload", () => {
|
||||
window.localStorage.setItem(
|
||||
messageSnapshotKey(RELAY, "chan-v2"),
|
||||
JSON.stringify({ version: 2, updatedAt: 1, events: [makeEvent()] }),
|
||||
);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-v2"), null);
|
||||
});
|
||||
|
||||
test("pending optimistic events are not persisted", () => {
|
||||
clearRelay();
|
||||
const settled = makeEvent({ id: "settled" });
|
||||
writeMessageSnapshot(RELAY, "chan-1", [
|
||||
settled,
|
||||
makeEvent({ id: "optimistic", pending: true }),
|
||||
]);
|
||||
assert.deepEqual(readMessageSnapshot(RELAY, "chan-1"), [settled]);
|
||||
});
|
||||
|
||||
test("write with only pending events persists nothing", () => {
|
||||
clearRelay();
|
||||
writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ pending: true })]);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-1"), null);
|
||||
});
|
||||
|
||||
test("snapshot keeps only the newest slice of a long timeline", () => {
|
||||
clearRelay();
|
||||
const events = Array.from({ length: 200 }, (_, i) =>
|
||||
makeEvent({ id: `event-${i}`, created_at: 1_700_000_000 + i }),
|
||||
);
|
||||
writeMessageSnapshot(RELAY, "chan-1", events);
|
||||
const persisted = readMessageSnapshot(RELAY, "chan-1");
|
||||
assert.equal(persisted.length, 80);
|
||||
assert.equal(persisted[persisted.length - 1].id, "event-199");
|
||||
assert.equal(persisted[0].id, "event-120");
|
||||
});
|
||||
|
||||
test("per-relay channel cap evicts the least recently written snapshot", () => {
|
||||
clearRelay();
|
||||
for (let i = 0; i < 21; i++) {
|
||||
writeMessageSnapshot(RELAY, `chan-${i}`, [makeEvent({ id: `e-${i}` })]);
|
||||
}
|
||||
// chan-0 was written first (oldest updatedAt tie broken by insertion) —
|
||||
// with 21 channels, at least one of the earliest must be evicted and the
|
||||
// newest retained.
|
||||
assert.notEqual(readMessageSnapshot(RELAY, "chan-20"), null);
|
||||
const retained = Array.from({ length: 21 }, (_, i) =>
|
||||
readMessageSnapshot(RELAY, `chan-${i}`),
|
||||
).filter((snapshot) => snapshot !== null);
|
||||
assert.equal(retained.length, 20);
|
||||
});
|
||||
|
||||
test("remove clears every snapshot for that relay only", () => {
|
||||
clearRelay();
|
||||
clearRelay("wss://other.example.com");
|
||||
writeMessageSnapshot(RELAY, "chan-1", [makeEvent({ id: "keep-other" })]);
|
||||
writeMessageSnapshot("wss://other.example.com", "chan-1", [
|
||||
makeEvent({ id: "other" }),
|
||||
]);
|
||||
removeMessageSnapshotsForRelay(RELAY);
|
||||
assert.equal(readMessageSnapshot(RELAY, "chan-1"), null);
|
||||
assert.notEqual(
|
||||
readMessageSnapshot("wss://other.example.com", "chan-1"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("write is tolerant of storage failures", () => {
|
||||
const original = window.localStorage.setItem;
|
||||
window.localStorage.setItem = () => {
|
||||
throw new Error("quota exceeded");
|
||||
};
|
||||
try {
|
||||
assert.doesNotThrow(() =>
|
||||
writeMessageSnapshot(RELAY, "chan-1", [makeEvent()]),
|
||||
);
|
||||
} finally {
|
||||
window.localStorage.setItem = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("cold snapshot load: merge keeps snapshot-only rows and widens aux backfill to them", () => {
|
||||
const snapshotOnly = makeEvent({ id: "ghost", created_at: 1_700_000_000 });
|
||||
const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 });
|
||||
const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({
|
||||
cached: undefined,
|
||||
snapshot: [snapshotOnly],
|
||||
history: [fresh],
|
||||
});
|
||||
assert.deepEqual(
|
||||
merged.map((event) => event.id),
|
||||
["ghost", "fresh"],
|
||||
);
|
||||
assert.ok(auxBackfillWindow.some((event) => event.id === "ghost"));
|
||||
assert.ok(auxBackfillWindow.some((event) => event.id === "fresh"));
|
||||
});
|
||||
|
||||
test("warm load: aux backfill stays scoped to the fresh window", () => {
|
||||
const cached = makeEvent({ id: "cached", created_at: 1_700_000_000 });
|
||||
const fresh = makeEvent({ id: "fresh", created_at: 1_700_000_100 });
|
||||
const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({
|
||||
cached: [cached],
|
||||
snapshot: [makeEvent({ id: "stale-snapshot" })],
|
||||
history: [fresh],
|
||||
});
|
||||
assert.ok(merged.some((event) => event.id === "cached"));
|
||||
assert.deepEqual(
|
||||
auxBackfillWindow.map((event) => event.id),
|
||||
["fresh"],
|
||||
);
|
||||
});
|
||||
|
||||
test("cold load without a snapshot backfills the fresh window only", () => {
|
||||
const fresh = makeEvent({ id: "fresh" });
|
||||
const { merged, auxBackfillWindow } = mergeHistoryOverSnapshot({
|
||||
cached: undefined,
|
||||
snapshot: null,
|
||||
history: [fresh],
|
||||
});
|
||||
assert.deepEqual(
|
||||
merged.map((event) => event.id),
|
||||
["fresh"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
auxBackfillWindow.map((event) => event.id),
|
||||
["fresh"],
|
||||
);
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Per-channel persisted message snapshots.
|
||||
*
|
||||
* A channel revisited after its React-Query cache entry is gone (app restart,
|
||||
* gcTime expiry, community remount) goes fully cold and holds a skeleton for a
|
||||
* relay round trip. This module persists the newest slice of each channel's
|
||||
* timeline so a revisit can paint instantly from the snapshot while the
|
||||
* history fetch revalidates behind it — the same stale-then-revalidate pattern
|
||||
* the sidebar's channelSnapshot uses for the channel list.
|
||||
*
|
||||
* Keyed per relay URL + channel id so one relay's messages never bleed into
|
||||
* another. Bounded two ways: only the newest MAX_EVENTS_PER_SNAPSHOT events
|
||||
* per channel, and only the MAX_CHANNELS_PER_RELAY most recently written
|
||||
* channels per relay (older ones are evicted LRU on write).
|
||||
*/
|
||||
|
||||
import { mergeTimelineHistoryMessages } from "@/features/messages/lib/messageQueryKeys";
|
||||
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
|
||||
|
||||
const STORAGE_KEY_PREFIX = "buzz-channel-messages.v1";
|
||||
|
||||
// Newest events kept per channel. The trailing slice of the sorted timeline
|
||||
// cache, so recent auxiliary events (reactions/edits) ride along with the
|
||||
// content rows they decorate.
|
||||
const MAX_EVENTS_PER_SNAPSHOT = 80;
|
||||
|
||||
const MAX_CHANNELS_PER_RELAY = 20;
|
||||
|
||||
export function messageSnapshotKey(relayUrl: string, channelId: string) {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:${channelId}`;
|
||||
}
|
||||
|
||||
type SnapshotPayload = {
|
||||
version: 1;
|
||||
updatedAt: number;
|
||||
events: RelayEvent[];
|
||||
};
|
||||
|
||||
function parseSnapshotPayload(json: unknown): SnapshotPayload | null {
|
||||
if (typeof json !== "object" || json === null) return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
if (obj.version !== 1 || !Array.isArray(obj.events)) return null;
|
||||
const updatedAt =
|
||||
typeof obj.updatedAt === "number" && Number.isFinite(obj.updatedAt)
|
||||
? obj.updatedAt
|
||||
: 0;
|
||||
return { version: 1, updatedAt, events: obj.events as RelayEvent[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the persisted message snapshot for a channel, or null when absent or
|
||||
* malformed.
|
||||
*/
|
||||
export function readMessageSnapshot(
|
||||
relayUrl: string,
|
||||
channelId: string,
|
||||
): RelayEvent[] | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(
|
||||
messageSnapshotKey(relayUrl, channelId),
|
||||
);
|
||||
if (!raw) return null;
|
||||
const parsed = parseSnapshotPayload(JSON.parse(raw));
|
||||
if (!parsed || parsed.events.length === 0) return null;
|
||||
return parsed.events;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function relayPrefix(relayUrl: string) {
|
||||
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`;
|
||||
}
|
||||
|
||||
function collectKeysWithPrefix(prefix: string): string[] {
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const key = window.localStorage.key(i);
|
||||
if (key?.startsWith(prefix)) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function evictOldestSnapshots(prefix: string, keepingKey: string) {
|
||||
const others = collectKeysWithPrefix(prefix).filter(
|
||||
(key) => key !== keepingKey,
|
||||
);
|
||||
if (others.length < MAX_CHANNELS_PER_RELAY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const byAge = others
|
||||
.map((key) => {
|
||||
let updatedAt = 0;
|
||||
try {
|
||||
const parsed = parseSnapshotPayload(
|
||||
JSON.parse(window.localStorage.getItem(key) ?? ""),
|
||||
);
|
||||
updatedAt = parsed?.updatedAt ?? 0;
|
||||
} catch {
|
||||
// Malformed entries sort oldest and get evicted first.
|
||||
}
|
||||
return { key, updatedAt };
|
||||
})
|
||||
.sort((a, b) => a.updatedAt - b.updatedAt);
|
||||
|
||||
for (const { key } of byAge.slice(
|
||||
0,
|
||||
others.length - (MAX_CHANNELS_PER_RELAY - 1),
|
||||
)) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the newest slice of a channel's timeline. Pending optimistic events
|
||||
* are dropped (they have no relay identity to revalidate against). Skips the
|
||||
* write when unchanged so live-append churn does not re-serialize an identical
|
||||
* snapshot. Non-fatal on storage failure (e.g. quota exceeded).
|
||||
*/
|
||||
export function writeMessageSnapshot(
|
||||
relayUrl: string,
|
||||
channelId: string,
|
||||
events: RelayEvent[],
|
||||
): void {
|
||||
try {
|
||||
const persistable = events
|
||||
.filter((event) => !event.pending)
|
||||
.slice(-MAX_EVENTS_PER_SNAPSHOT);
|
||||
if (persistable.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = messageSnapshotKey(relayUrl, channelId);
|
||||
const previous = window.localStorage.getItem(key);
|
||||
if (previous) {
|
||||
const parsed = parseSnapshotPayload(JSON.parse(previous));
|
||||
if (
|
||||
parsed &&
|
||||
JSON.stringify(parsed.events) === JSON.stringify(persistable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
evictOldestSnapshots(relayPrefix(relayUrl), key);
|
||||
setLocalStorageItemWithRecovery(
|
||||
key,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
events: persistable,
|
||||
} satisfies SnapshotPayload),
|
||||
);
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a fresh history window over the in-memory cache — or, when cold, over
|
||||
* the persisted snapshot — and pick the window aux backfill must cover.
|
||||
*
|
||||
* The snapshot can hold events older than the fetch window; dropping them on
|
||||
* settle would visibly shrink an already-painted timeline, so the merge keeps
|
||||
* them. But a kept snapshot row deleted/edited while the app was closed never
|
||||
* reappears in any history fetch (the relay soft-deletes), so its tombstone or
|
||||
* edit is only reachable by `#e` over that row's id — cold snapshot loads must
|
||||
* therefore backfill over the merged timeline, not just the fresh window.
|
||||
* Otherwise the ghost paints, and the post-settle snapshot rewrite persists it
|
||||
* forever.
|
||||
*/
|
||||
export function mergeHistoryOverSnapshot(input: {
|
||||
cached: RelayEvent[] | undefined;
|
||||
snapshot: RelayEvent[] | null;
|
||||
history: RelayEvent[];
|
||||
}): { merged: RelayEvent[]; auxBackfillWindow: RelayEvent[] } {
|
||||
const usedSnapshot = !input.cached && input.snapshot !== null;
|
||||
const merged = mergeTimelineHistoryMessages(
|
||||
input.cached ?? input.snapshot ?? [],
|
||||
input.history,
|
||||
);
|
||||
return { merged, auxBackfillWindow: usedSnapshot ? merged : input.history };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes every channel message snapshot for a relay. Called when a community
|
||||
* is removed.
|
||||
*/
|
||||
export function removeMessageSnapshotsForRelay(relayUrl: string): void {
|
||||
try {
|
||||
for (const key of collectKeysWithPrefix(relayPrefix(relayUrl))) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Storage access failures are non-fatal.
|
||||
}
|
||||
}
|
||||
@@ -363,3 +363,56 @@ test("test_pageless_live_projection_preserves_cached_timeline", () => {
|
||||
assert.deepEqual(contents(harness), ["initial", "live"]);
|
||||
assert.equal(harness.client.getQueryData(harness.messagesKey)[0], cached[0]);
|
||||
});
|
||||
|
||||
test("test_concurrent_refreshes_after_seeded_snapshot_share_one_authoritative_fetch", async () => {
|
||||
// Subscribe settlement and a reconnect can both call the helper while the
|
||||
// channel query is still parked on its hydration-seeded snapshot. Both wake
|
||||
// on the same promise; the second invalidation must join the first
|
||||
// authoritative fetch, not cancel and replace it (Max/Wren, #6572 review).
|
||||
const harness = createHarness();
|
||||
const seeded = event("seeded", 100);
|
||||
harness.client.setQueryData(harness.messagesKey, [seeded], { updatedAt: 0 });
|
||||
const requests = [];
|
||||
const observer = new QueryObserver(harness.client, {
|
||||
queryKey: harness.messagesKey,
|
||||
queryFn: async ({ signal }) => {
|
||||
const previousMessages = harness.client.getQueryData(harness.messagesKey);
|
||||
let resolveFetch;
|
||||
const fetch = new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
requests.push({ resolveFetch, signal });
|
||||
const events = await fetch;
|
||||
return reconcileFetchedChannelWindow(
|
||||
harness.client,
|
||||
harness.channelId,
|
||||
events,
|
||||
previousMessages,
|
||||
signal,
|
||||
);
|
||||
},
|
||||
});
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
try {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(requests.length, 1);
|
||||
const first = refreshChannelWindowMessages(
|
||||
harness.client,
|
||||
harness.channelId,
|
||||
);
|
||||
const second = refreshChannelWindowMessages(
|
||||
harness.client,
|
||||
harness.channelId,
|
||||
);
|
||||
requests[0].resolveFetch(wirePage([seeded]));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(requests.length, 2);
|
||||
requests[1].resolveFetch(wirePage([event("gap", 110), seeded]));
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[1].signal.aborted, false);
|
||||
assert.deepEqual(contents(harness), ["seeded", "gap"]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ChannelWindowStore,
|
||||
} from "./channelWindowStore";
|
||||
import { reconcileChannelWindowMessages } from "./channelWindowReconciliation";
|
||||
import { channelHeadHydration } from "./channelHeadCache";
|
||||
|
||||
/** Keep the rendered timeline cache aligned with its authoritative window. */
|
||||
export function projectChannelWindowMessages(
|
||||
@@ -26,10 +27,27 @@ export async function refreshChannelWindowMessages(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: channelMessagesKey(channelId),
|
||||
exact: true,
|
||||
refetchType: "active",
|
||||
});
|
||||
const queryKey = channelMessagesKey(channelId);
|
||||
// Sequence behind persisted-head hydration. While the channel query is parked
|
||||
// on that gate it has no data, so TanStack would dedupe this invalidation
|
||||
// onto it — and that fetch returns the seeded snapshot, never asking the
|
||||
// relay. A seeded query is recognisable by data at `dataUpdatedAt` 0; let its
|
||||
// snapshot fetch settle (consuming the mount gate) before invalidating, so
|
||||
// the refetch is a distinct authoritative window fetch. Concurrent callers
|
||||
// (subscribe settlement + reconnect) wake on the same promise, so the seeded
|
||||
// branch must join an authoritative fetch already in flight rather than
|
||||
// cancel and replace it. Cold and warm channels carry no such marker and
|
||||
// dedupe/cancel exactly as before.
|
||||
await channelHeadHydration(queryClient);
|
||||
const query = queryClient.getQueryCache().find({ queryKey, exact: true });
|
||||
const seeded =
|
||||
query?.state.data !== undefined && query.state.dataUpdatedAt === 0;
|
||||
if (seeded) {
|
||||
await query.promise?.catch(() => {});
|
||||
}
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey, exact: true, refetchType: "active" },
|
||||
{ cancelRefetch: !seeded },
|
||||
);
|
||||
projectChannelWindowMessages(queryClient, channelId);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
createOptimisticMessage,
|
||||
resolveCachedReplyRootId,
|
||||
resolveEffectiveChannel,
|
||||
resolveSendChannel,
|
||||
resolveThreadReplyTarget,
|
||||
@@ -322,3 +323,23 @@ test("resolveThreadReplyTarget_nullContext_noLiveRefs_returnsNull", () => {
|
||||
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test("resolveCachedReplyRootId sends only roots proven by a cached parent", () => {
|
||||
const rootId = "1".repeat(64);
|
||||
const parentId = "2".repeat(64);
|
||||
const parent = {
|
||||
id: parentId,
|
||||
pubkey: IDENTITY.pubkey,
|
||||
kind: 9,
|
||||
created_at: 1,
|
||||
content: "parent",
|
||||
tags: [
|
||||
["e", rootId, "", "root"],
|
||||
["e", rootId, "", "reply"],
|
||||
],
|
||||
sig: "",
|
||||
};
|
||||
|
||||
assert.equal(resolveCachedReplyRootId(parentId, [[], [parent]]), rootId);
|
||||
assert.equal(resolveCachedReplyRootId(parentId, [[], []]), null);
|
||||
});
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { collectThreadAuxMessageIds } from "./useThreadReplies.ts";
|
||||
|
||||
const ROOT_ID = "1".repeat(64);
|
||||
const REPLY_ID = "2".repeat(64);
|
||||
|
||||
function reply(id = REPLY_ID) {
|
||||
return {
|
||||
id,
|
||||
pubkey: "a".repeat(64),
|
||||
kind: 9,
|
||||
created_at: 1_700_000_000,
|
||||
content: "reply",
|
||||
tags: [["e", ROOT_ID]],
|
||||
sig: "sig",
|
||||
};
|
||||
}
|
||||
|
||||
test("thread aux hydration includes the root when there are no replies", () => {
|
||||
assert.deepEqual(collectThreadAuxMessageIds(ROOT_ID, []), [ROOT_ID]);
|
||||
});
|
||||
|
||||
test("thread aux hydration includes and deduplicates root and reply ids", () => {
|
||||
assert.deepEqual(
|
||||
collectThreadAuxMessageIds(ROOT_ID, [reply(), reply(ROOT_ID)]),
|
||||
[ROOT_ID, REPLY_ID],
|
||||
test("thread replies trust the relay-provided aux closure", async () => {
|
||||
const source = await readFile(
|
||||
new URL("./useThreadReplies.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/withThreadAux|fetchStructuralAuxForMessages|fetchAuxEventsByReference/,
|
||||
);
|
||||
assert.match(source, /replies\.push\(\.\.\.response\.events\)/);
|
||||
});
|
||||
|
||||
@@ -5,76 +5,16 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
collectMessageIdsForAuxBackfill,
|
||||
fetchStructuralAuxForMessages,
|
||||
} from "@/features/messages/lib/auxBackfill";
|
||||
import {
|
||||
threadRepliesKey,
|
||||
sortMessages,
|
||||
} from "@/features/messages/lib/messageQueryKeys";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
|
||||
import { getThreadReplies } from "@/shared/api/tauri";
|
||||
import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types";
|
||||
|
||||
const THREAD_PAGE_LIMIT = 200;
|
||||
const MAX_THREAD_PAGES = 500;
|
||||
|
||||
/**
|
||||
* Append the structural aux closure (edits/deletions) for the fetched replies.
|
||||
* The server thread-subtree query resolves deletions itself but omits
|
||||
* kind:40003 edits, so a bare refetch would render every edited reply with its
|
||||
* original text. Best-effort: an aux failure logs and returns the replies
|
||||
* unadorned rather than failing the whole thread load.
|
||||
*/
|
||||
async function fetchThreadAuxBestEffort(
|
||||
label: string,
|
||||
channelId: string,
|
||||
fetchAux: () => Promise<RelayEvent[]>,
|
||||
): Promise<RelayEvent[]> {
|
||||
try {
|
||||
return await fetchAux();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to backfill thread reply ${label} for channel`,
|
||||
channelId,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function collectThreadAuxMessageIds(
|
||||
threadRootId: string,
|
||||
replies: RelayEvent[],
|
||||
): string[] {
|
||||
return [
|
||||
...new Set([threadRootId, ...collectMessageIdsForAuxBackfill(replies)]),
|
||||
];
|
||||
}
|
||||
|
||||
async function withThreadAux(
|
||||
channelId: string,
|
||||
threadRootId: string,
|
||||
replies: RelayEvent[],
|
||||
): Promise<RelayEvent[]> {
|
||||
const messageIds = collectThreadAuxMessageIds(threadRootId, replies);
|
||||
const [structuralAux, reactions] = await Promise.all([
|
||||
fetchThreadAuxBestEffort("structural aux", channelId, () =>
|
||||
fetchStructuralAuxForMessages(channelId, messageIds),
|
||||
),
|
||||
fetchThreadAuxBestEffort("reactions", channelId, () =>
|
||||
relayClient.fetchAuxEventsByReference(
|
||||
channelId,
|
||||
messageIds,
|
||||
buildChannelReactionAuxFilter,
|
||||
),
|
||||
),
|
||||
]);
|
||||
return sortMessages([...replies, ...structuralAux, ...reactions]);
|
||||
}
|
||||
|
||||
async function loadThreadReplies(
|
||||
queryClient: QueryClient,
|
||||
channelId: string,
|
||||
@@ -92,12 +32,11 @@ async function loadThreadReplies(
|
||||
});
|
||||
replies.push(...response.events);
|
||||
if (!response.nextCursor) {
|
||||
const fetched = await withThreadAux(channelId, rootId, replies);
|
||||
const current = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
|
||||
const receivedInFlight = current.filter(
|
||||
(event) => !idsAtStart.has(event.id),
|
||||
);
|
||||
return sortMessages([...fetched, ...receivedInFlight]);
|
||||
return sortMessages([...replies, ...receivedInFlight]);
|
||||
}
|
||||
cursor = response.nextCursor;
|
||||
}
|
||||
|
||||
@@ -284,8 +284,8 @@ export function useUserProfileQuery(pubkey?: string) {
|
||||
|
||||
// Per-pubkey resolution cache backing `useUsersBatchQuery`'s delta fetch.
|
||||
// `summary: null` records a relay-confirmed miss so unknown pubkeys aren't
|
||||
// re-requested every page. Entries older than the hook's 60s staleTime are
|
||||
// treated as unresolved and refetched.
|
||||
// re-requested every page. Entries older than the hook's 10-minute staleTime
|
||||
// are treated as unresolved and refetched.
|
||||
export type UsersBatchEntry = {
|
||||
summary: UserProfileSummary | null;
|
||||
fetchedAt: number;
|
||||
@@ -301,7 +301,8 @@ export const usersBatchEntryKey = (pubkey: string) => [
|
||||
* run re-fetches these profiles from the relay. Must be called anywhere a
|
||||
* specific profile (or a containing `users-batch` query) is invalidated —
|
||||
* otherwise the re-run resolves from the still-fresh-looking entry and
|
||||
* renders the stale name/avatar for up to the entry's 60s freshness window.
|
||||
* renders the stale name/avatar for up to the entry's 10-minute freshness
|
||||
* window.
|
||||
* Synchronous, so callers can evict before awaiting aggregate invalidations.
|
||||
*/
|
||||
export function evictUsersBatchEntries(
|
||||
@@ -351,7 +352,7 @@ export function useUsersBatchQuery(
|
||||
const entry = queryClient.getQueryData<UsersBatchEntry>(
|
||||
usersBatchEntryKey(pubkey),
|
||||
);
|
||||
if (entry && now - entry.fetchedAt < 60_000) {
|
||||
if (entry && now - entry.fetchedAt < 10 * 60_000) {
|
||||
if (entry.summary) profiles[pubkey] = entry.summary;
|
||||
else missing.push(pubkey);
|
||||
} else {
|
||||
@@ -384,7 +385,7 @@ export function useUsersBatchQuery(
|
||||
relayUrl,
|
||||
normalizedPubkeys,
|
||||
),
|
||||
staleTime: 60_000,
|
||||
staleTime: 10 * 60_000,
|
||||
gcTime: 5 * 60 * 1_000,
|
||||
});
|
||||
|
||||
|
||||
25
desktop/src/shared/api/tauriChannelHeadCache.ts
Normal file
25
desktop/src/shared/api/tauriChannelHeadCache.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
export type ChannelHeadScope = { pubkey: string; relayUrl: string };
|
||||
export type ChannelHeadEntry = {
|
||||
channelId: string;
|
||||
events: RelayEvent[];
|
||||
savedAt: number;
|
||||
lastVisitedAt: number;
|
||||
};
|
||||
export function loadChannelHeadCache(
|
||||
scope: ChannelHeadScope,
|
||||
limit = 12,
|
||||
): Promise<ChannelHeadEntry[]> {
|
||||
return invokeTauri("channel_head_cache_load", { scope, limit });
|
||||
}
|
||||
export function storeChannelHeadCache(
|
||||
scope: ChannelHeadScope,
|
||||
channelId: string,
|
||||
events: RelayEvent[],
|
||||
): Promise<void> {
|
||||
return invokeTauri("channel_head_cache_store", { scope, channelId, events });
|
||||
}
|
||||
export function clearChannelHeadCache(scope: ChannelHeadScope): Promise<void> {
|
||||
return invokeTauri("channel_head_cache_clear", { scope });
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export async function sendChannelMessage(
|
||||
sentFromThreadTag?: string[],
|
||||
expectedRelayUrl?: string,
|
||||
expectedSignerPubkey?: string,
|
||||
rootEventId?: string | null,
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
@@ -22,6 +23,7 @@ export async function sendChannelMessage(
|
||||
channelId,
|
||||
content,
|
||||
parentEventId,
|
||||
rootEventId: rootEventId ?? null,
|
||||
mediaTags: mediaTags ?? null,
|
||||
emojiTags: emojiTags ?? null,
|
||||
mentionTags: mentionTags ?? null,
|
||||
|
||||
@@ -370,6 +370,8 @@ type E2eConfig = {
|
||||
/** Delay (ms) applied to continuation channel-window requests so e2e
|
||||
* tests can observe the in-flight prepend window. 0/undefined = instant. */
|
||||
channelWindowDelayMs?: number;
|
||||
/** Delay (ms) applied to newest-page channel-window requests. */
|
||||
channelHeadDelayMs?: number;
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
/** Override whether get_profile reports a real kind:0 event. */
|
||||
@@ -5546,19 +5548,20 @@ async function handleGetChannelWindow(
|
||||
return relayQuery(config, [filter]);
|
||||
};
|
||||
|
||||
if (!args.cursor) {
|
||||
return execute();
|
||||
}
|
||||
|
||||
const probe = window as unknown as {
|
||||
__CHANNEL_WINDOW_FETCH_COUNT__?: number;
|
||||
__CHANNEL_WINDOW_INFLIGHT__?: number;
|
||||
__CHANNEL_WINDOW_INFLIGHT_PEAK__?: number;
|
||||
};
|
||||
probe.__CHANNEL_WINDOW_FETCH_COUNT__ =
|
||||
(probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1;
|
||||
if (args.cursor !== null) {
|
||||
probe.__CHANNEL_WINDOW_FETCH_COUNT__ =
|
||||
(probe.__CHANNEL_WINDOW_FETCH_COUNT__ ?? 0) + 1;
|
||||
}
|
||||
|
||||
const delayMs = getConfig()?.mock?.channelWindowDelayMs ?? 0;
|
||||
const delayMs =
|
||||
args.cursor === null
|
||||
? (getConfig()?.mock?.channelHeadDelayMs ?? 0)
|
||||
: (getConfig()?.mock?.channelWindowDelayMs ?? 0);
|
||||
if (delayMs <= 0) {
|
||||
return execute();
|
||||
}
|
||||
@@ -9577,6 +9580,7 @@ async function handleSendChannelMessage(
|
||||
channelId: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
rootEventId?: string | null;
|
||||
kind?: number | null;
|
||||
mentionPubkeys?: string[];
|
||||
mediaTags?: string[][] | null;
|
||||
@@ -9699,7 +9703,8 @@ async function handleSendChannelMessage(
|
||||
parentEventId: null,
|
||||
rootEventId: null,
|
||||
};
|
||||
const rootEventId = parentThread.rootEventId ?? args.parentEventId;
|
||||
const rootEventId =
|
||||
args.rootEventId ?? parentThread.rootEventId ?? args.parentEventId;
|
||||
const depth = parentEvent
|
||||
? (() => {
|
||||
let currentEvent: RelayEvent | undefined = parentEvent;
|
||||
@@ -9758,7 +9763,7 @@ async function handleSendChannelMessage(
|
||||
args.channelId,
|
||||
relayIdentity.pubkey,
|
||||
args.parentEventId,
|
||||
args.parentEventId,
|
||||
args.rootEventId ?? args.parentEventId,
|
||||
args.mentionPubkeys,
|
||||
)
|
||||
: buildTopLevelMessageTags(
|
||||
@@ -9776,8 +9781,12 @@ async function handleSendChannelMessage(
|
||||
return {
|
||||
event_id: result.event_id,
|
||||
parent_event_id: args.parentEventId ?? null,
|
||||
root_event_id: args.parentEventId ?? null,
|
||||
depth: args.parentEventId ? 1 : 0,
|
||||
root_event_id: args.rootEventId ?? args.parentEventId ?? null,
|
||||
depth: args.parentEventId
|
||||
? args.rootEventId && args.rootEventId !== args.parentEventId
|
||||
? 2
|
||||
: 1
|
||||
: 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
@@ -13889,6 +13898,59 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return null;
|
||||
case "fetch_persona_catalog":
|
||||
return mockPersonaCatalogPublications();
|
||||
case "channel_head_cache_load": {
|
||||
const args = payload as {
|
||||
scope: { pubkey: string; relayUrl: string };
|
||||
limit: number;
|
||||
};
|
||||
const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`;
|
||||
const entries = JSON.parse(
|
||||
window.localStorage.getItem(key) ?? "[]",
|
||||
) as Array<{
|
||||
channelId: string;
|
||||
events: RelayEvent[];
|
||||
savedAt: number;
|
||||
lastVisitedAt: number;
|
||||
}>;
|
||||
return entries
|
||||
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)
|
||||
.slice(0, args.limit);
|
||||
}
|
||||
case "channel_head_cache_store": {
|
||||
const args = payload as {
|
||||
scope: { pubkey: string; relayUrl: string };
|
||||
channelId: string;
|
||||
events: RelayEvent[];
|
||||
};
|
||||
const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`;
|
||||
const entries = JSON.parse(
|
||||
window.localStorage.getItem(key) ?? "[]",
|
||||
) as Array<{
|
||||
channelId: string;
|
||||
events: RelayEvent[];
|
||||
savedAt: number;
|
||||
lastVisitedAt: number;
|
||||
}>;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const next = entries
|
||||
.filter((entry) => entry.channelId !== args.channelId)
|
||||
.concat({
|
||||
channelId: args.channelId,
|
||||
events: args.events,
|
||||
savedAt: now,
|
||||
lastVisitedAt: now,
|
||||
})
|
||||
.sort((left, right) => right.lastVisitedAt - left.lastVisitedAt)
|
||||
.slice(0, 32);
|
||||
window.localStorage.setItem(key, JSON.stringify(next));
|
||||
return null;
|
||||
}
|
||||
case "channel_head_cache_clear": {
|
||||
const args = payload as { scope: { pubkey: string; relayUrl: string } };
|
||||
const key = `buzz-e2e-channel-head:${args.scope.pubkey.toLowerCase()}:${args.scope.relayUrl.toLowerCase().replace(/\/$/, "")}`;
|
||||
window.localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
case "observed_unread_open_scope": {
|
||||
const request = payload as {
|
||||
request: {
|
||||
|
||||
88
desktop/tests/e2e/channel-head-restart.spec.ts
Normal file
88
desktop/tests/e2e/channel-head-restart.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const PERSISTED_ONLY = "persisted restart head";
|
||||
|
||||
test("restart paints a persisted head before the single authoritative refresh", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(
|
||||
() => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function",
|
||||
);
|
||||
await page.evaluate((content) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
content,
|
||||
});
|
||||
}, PERSISTED_ONLY);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByText(PERSISTED_ONLY)).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter(
|
||||
(command) => command === "channel_head_cache_store",
|
||||
).length,
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const persistedCache = await page.evaluate(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(window.localStorage).filter(([key]) =>
|
||||
key.startsWith("buzz-e2e-channel-head:"),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(Object.keys(persistedCache)).toHaveLength(1);
|
||||
await page.addInitScript((cache) => {
|
||||
for (const [key, value] of Object.entries(cache)) {
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
const testWindow = window as Window & {
|
||||
__BUZZ_E2E__?: { mock?: Record<string, unknown> };
|
||||
};
|
||||
testWindow.__BUZZ_E2E__ = {
|
||||
...testWindow.__BUZZ_E2E__,
|
||||
mock: {
|
||||
...testWindow.__BUZZ_E2E__?.mock,
|
||||
channelHeadDelayMs: 5_000,
|
||||
},
|
||||
};
|
||||
}, persistedCache);
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible();
|
||||
const restartedCallsBeforeOpen = await page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter(
|
||||
(command) => command === "get_channel_window",
|
||||
).length,
|
||||
);
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
// The mock relay fetch is held for 5s. Seeing this row inside 2s proves
|
||||
// restart hydration painted persisted data rather than waiting for the relay.
|
||||
await expect(page.getByText(PERSISTED_ONLY)).toBeVisible({ timeout: 2_000 });
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(callsBeforeOpen) =>
|
||||
(window.__BUZZ_E2E_COMMANDS__ ?? []).filter(
|
||||
(command) => command === "get_channel_window",
|
||||
).length - callsBeforeOpen,
|
||||
restartedCallsBeforeOpen,
|
||||
),
|
||||
)
|
||||
.toBe(1);
|
||||
|
||||
// Reload resets the mock relay store, so the authoritative refresh omits the
|
||||
// persisted-only row and must replace it wholesale.
|
||||
await expect(page.getByText(PERSISTED_ONLY)).toHaveCount(0, {
|
||||
timeout: 8_000,
|
||||
});
|
||||
});
|
||||
@@ -368,9 +368,16 @@ test("service restart close resets accumulated backoff", async ({ page }) => {
|
||||
websocketConnectErrors: ["down 1", "down 2", "down 3"],
|
||||
});
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("channel-general")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
// Three rejected dials put the session deep in its backoff loop before it
|
||||
// connects; wait for that connect rather than for the Tauri-backed channel
|
||||
// list, which paints long before the websocket is up.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe("connected");
|
||||
|
||||
const startedAt = Date.now();
|
||||
await restartMockWebsockets(page);
|
||||
|
||||
@@ -304,6 +304,8 @@ type MockBridgeOptions = {
|
||||
usersBatchDelayMs?: number;
|
||||
/** Delay (ms) for older-history fetches; see e2eBridge mock config. */
|
||||
channelWindowDelayMs?: number;
|
||||
/** Delay (ms) for newest-page fetches; see e2eBridge mock config. */
|
||||
channelHeadDelayMs?: number;
|
||||
profileReadDelayMs?: number;
|
||||
profileReadError?: string;
|
||||
/** Override whether get_profile reports a real kind:0 event. */
|
||||
|
||||
@@ -87,7 +87,9 @@ Clients **partition by kind before any cursor math**:
|
||||
2. **Aux closure** (`include_aux`) — reactions (7), deletions (5, 9005),
|
||||
and edits (40003) targeting the retained rows by `#e`, **plus**
|
||||
deletions targeting those aux events (the transitive second hop, e.g.
|
||||
a delete-of-a-reaction). One round trip; no client `#e` fan-out.
|
||||
a delete-of-a-reaction). One round trip; no client `#e` fan-out. Each
|
||||
hop is drained server-side across the DB page clamp, so the closure is
|
||||
complete rather than newest-1000.
|
||||
3. **Thread summaries** (`include_summaries`) — one relay-signed
|
||||
`kind:39005` per row that has replies.
|
||||
4. **Window bounds** — exactly one relay-signed `kind:39006` per window
|
||||
@@ -126,7 +128,9 @@ Both kinds are relay-only: client submission is rejected at ingest.
|
||||
- Reconnect refetches page 0 and re-arms the live subscription
|
||||
(`since: now`); deeper pages need no repair path.
|
||||
- Replies never enter the channel timeline; the thread panel uses the
|
||||
existing `thread_cursor` surface (#1418).
|
||||
existing `thread_cursor` surface (#1418). Thread filters may opt into
|
||||
`include_aux` to append the same authorized two-hop reactions, edits, and
|
||||
deletions closure as a channel-window response.
|
||||
|
||||
## Siblings
|
||||
|
||||
|
||||
Reference in New Issue
Block a user