From dfa822c192aec6dd54215efb6f1f30e30d4bde8d Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 13 Aug 2026 14:10:13 -0600 Subject: [PATCH] fix(messages): preserve legacy thread paging Make the authoritative thread bounds overlay an explicit Desktop opt-in so legacy clients continue cursoring from real replies. Move warm-channel settle tracking out of render and bound its session state, then remove the dead smoke pattern. Co-authored-by: Carl Signed-off-by: Wes --- crates/buzz-relay/src/api/bridge.rs | 92 +++++++++++-------- desktop/playwright.config.ts | 1 - desktop/src-tauri/src/commands/messages.rs | 3 + .../src-tauri/src/commands/messages_tests.rs | 1 + .../features/messages/ui/MessageTimeline.tsx | 47 +++++++--- 5 files changed, 90 insertions(+), 54 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10532afdd..3336454c2 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1196,6 +1196,7 @@ async fn query_events_authed( .max(1) as u32; let thread_cursor = extract_thread_cursor(raw); let thread_order = extract_thread_order(raw); + let include_thread_bounds = extension_flag(raw, "thread_bounds"); let thread_page = state .db .get_thread_replies_ordered( @@ -1225,44 +1226,47 @@ async fn query_events_authed( } } - // Relay-signed, query-time pagination authority. The cursor describes - // the raw scan position, not the last event that happened to survive - // reconstruction or authorization filtering. - let next_cursor = thread_page.next_cursor.as_ref().map(|(ts, id)| { - serde_json::json!({ - "created_at": ts.timestamp(), - "id": hex::encode(id), - }) - }); - let content = serde_json::json!({ - "has_more": thread_page.has_more, - "next_cursor": next_cursor, - }); - let order = match thread_order { - buzz_db::thread::ThreadOrder::Oldest => "oldest", - buzz_db::thread::ThreadOrder::Newest => "newest", - }; - let request_cursor = thread_cursor - .as_deref() - .map(hex::encode) - .unwrap_or_else(|| "head".to_owned()); - let tags = vec![ - nostr::Tag::parse(["e", root_hex]) - .map_err(|e| internal_error(&format!("thread bounds e tag: {e}")))?, - nostr::Tag::parse(["d", &format!("{root_hex}:{order}:{request_cursor}")]) - .map_err(|e| internal_error(&format!("thread bounds d tag: {e}")))?, - ]; - let overlay = nostr::EventBuilder::new( - nostr::Kind::Custom(buzz_core::kind::KIND_THREAD_BOUNDS as u16), - content.to_string(), - ) - .tags(tags) - .sign_with_keys(&state.relay_keypair) - .map_err(|e| internal_error(&format!("thread bounds sign: {e}")))?; - events.push( - serde_json::to_value(&overlay) - .map_err(|e| internal_error(&format!("thread bounds serialize: {e}")))?, - ); + // Relay-signed, query-time pagination authority, explicitly requested + // by clients that understand the overlay. Legacy clients page by the + // last returned reply, so appending a protocol event unconditionally + // would turn that overlay into their cursor and truncate large threads. + if include_thread_bounds { + let next_cursor = thread_page.next_cursor.as_ref().map(|(ts, id)| { + serde_json::json!({ + "created_at": ts.timestamp(), + "id": hex::encode(id), + }) + }); + let content = serde_json::json!({ + "has_more": thread_page.has_more, + "next_cursor": next_cursor, + }); + let order = match thread_order { + buzz_db::thread::ThreadOrder::Oldest => "oldest", + buzz_db::thread::ThreadOrder::Newest => "newest", + }; + let request_cursor = thread_cursor + .as_deref() + .map(hex::encode) + .unwrap_or_else(|| "head".to_owned()); + let tags = vec![ + nostr::Tag::parse(["e", root_hex]) + .map_err(|e| internal_error(&format!("thread bounds e tag: {e}")))?, + nostr::Tag::parse(["d", &format!("{root_hex}:{order}:{request_cursor}")]) + .map_err(|e| internal_error(&format!("thread bounds d tag: {e}")))?, + ]; + let overlay = nostr::EventBuilder::new( + nostr::Kind::Custom(buzz_core::kind::KIND_THREAD_BOUNDS as u16), + content.to_string(), + ) + .tags(tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| internal_error(&format!("thread bounds sign: {e}")))?; + events.push( + serde_json::to_value(&overlay) + .map_err(|e| internal_error(&format!("thread bounds serialize: {e}")))?, + ); + } handled.insert(idx); } @@ -3070,6 +3074,18 @@ mod tests { &serde_json::json!({ "top_level": 1 }), "top_level" )); + assert!(extension_flag( + &serde_json::json!({ "thread_bounds": true }), + "thread_bounds" + )); + assert!(!extension_flag( + &serde_json::json!({ "thread_bounds": false }), + "thread_bounds" + )); + assert!(!extension_flag( + &serde_json::json!({ "thread_bounds": "true" }), + "thread_bounds" + )); } #[test] diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 70c8fdd94..a9cd5dba5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -35,7 +35,6 @@ export default defineConfig({ "**/hosted-communities-settings-screenshots.spec.ts", "**/invites-settings-screenshots.spec.ts", "**/message-performance.spec.ts", - "**/thread-newest-ab.perf.ts", "**/messaging.spec.ts", "**/message-feedback-snapshots.spec.ts", "**/custom-emoji.spec.ts", diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 07a1f76a5..479a13a63 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -300,6 +300,9 @@ 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)); + // Opt into the relay's authoritative raw-scan bounds overlay. Older clients + // do not understand protocol events in this response and must not receive it. + filter.insert("thread_bounds".to_string(), serde_json::json!(true)); if matches!(thread_order, Some("newest")) { filter.insert("thread_order".to_string(), serde_json::json!("newest")); } diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index 3a469a025..ab2b37f2d 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -178,6 +178,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["thread_bounds"], serde_json::json!(true)); assert_eq!(filter["#h"], serde_json::json!(["channel-1"])); } diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index d4208df22..f4186324a 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -265,31 +265,48 @@ const MessageTimelineBase = React.forwardRef< ); // Cold loads keep the deferred markdown gate. Once a channel has committed, // a warm revisit can paint its cached snapshot immediately while refresh - // work continues behind it. - const requiresDeferredSettleRef = React.useRef(new Map()); + // work continues behind it. Update this bounded session cache after commit, + // not during render: render-phase mutation made sibling timeline effects see + // a source that had never actually settled. + const [settledChannelIds, setSettledChannelIds] = React.useState< + ReadonlySet + >(() => new Set()); const snapshotChannelId = liveSnapshot.channelId; - if (snapshotChannelId) { - if (!requiresDeferredSettleRef.current.has(snapshotChannelId)) { - requiresDeferredSettleRef.current.set(snapshotChannelId, isLoading); - } else if (isLoading) { - requiresDeferredSettleRef.current.set(snapshotChannelId, true); - } + React.useEffect(() => { if ( - !isLoading && - deferredSnapshot.channelId === snapshotChannelId && - messages.length > 0 + !snapshotChannelId || + isLoading || + deferredSnapshot.channelId !== snapshotChannelId || + messages.length === 0 ) { - requiresDeferredSettleRef.current.set(snapshotChannelId, false); + return; } - } + setSettledChannelIds((current) => { + if (current.has(snapshotChannelId)) return current; + const next = new Set(current); + next.add(snapshotChannelId); + // Channel ids are only a warm-paint hint. Bound their session lifetime so + // traversing many channels cannot grow this component state forever. + while (next.size > 32) { + const oldest = next.values().next().value; + if (oldest === undefined) break; + next.delete(oldest); + } + return next; + }); + }, [ + deferredSnapshot.channelId, + isLoading, + messages.length, + snapshotChannelId, + ]); const renderSource = selectTimelineRenderSource({ deferredChannelId: deferredSnapshot.channelId, liveChannelId: snapshotChannelId, preferLiveSnapshot: !isLoading && messages.length > 0 && - (snapshotChannelId === null || - requiresDeferredSettleRef.current.get(snapshotChannelId) === false), + (snapshotChannelId === null || settledChannelIds.has(snapshotChannelId)), }); const renderedSnapshot = renderSource === "live"