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 <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
Wes
2026-08-13 14:10:13 -06:00
co-authored by Carl
parent b3a83e0334
commit dfa822c192
5 changed files with 90 additions and 54 deletions
+54 -38
View File
@@ -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]
-1
View File
@@ -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",
@@ -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"));
}
@@ -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"]));
}
@@ -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<string, boolean>());
// 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<string>
>(() => 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"