mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user