Live thread-summary push: badge counts update on reply ingest (#1521)

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-07-06 10:25:32 -04:00
committed by GitHub
parent a255a77358
commit 74a30c3de8
9 changed files with 443 additions and 10 deletions

View File

@@ -2047,6 +2047,20 @@ async fn ingest_event_inner(
}
}
// A freshly inserted reply changed its thread's counters (updated in the
// same transaction as the insert) — push a fresh relay-signed 39005 so
// subscribed clients can update badge counts without refetching the head
// window. Page responses recompute summaries independently, so this is
// fan-out-only and best-effort.
if let Some(meta) = &thread_meta {
crate::handlers::side_effects::emit_live_thread_summary(
tenant,
state,
meta.channel_id,
meta.root_event_id.clone(),
);
}
let pubkey_hex = auth.pubkey().to_hex();
// Spec WriteInsert (line 514) / WriteInsertGlobal (line 559) /
// WriteDuplicate (line 606): emit the abstract write at the trailing

View File

@@ -11,7 +11,9 @@ use buzz_core::kind::{
KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED,
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS,
KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION,
KIND_THREAD_SUMMARY,
};
use buzz_core::StoredEvent;
use buzz_db::channel::MemberRole;
use super::event::dispatch_persistent_event;
@@ -684,6 +686,108 @@ pub async fn emit_system_message(
Ok(())
}
/// Sign and fan out a fresh relay-signed `kind:39005` thread-summary overlay
/// for `root_id` after a thread mutation (reply insert or threaded delete).
///
/// Fan-out only — never stored. Channel-window pages recompute summaries from
/// `thread_metadata` on every fetch (`api/bridge.rs`), so a persisted copy
/// would only add staleness; this live emit exists purely so subscribed
/// clients can update badge counts without refetching the head window. The
/// counts are re-read from `thread_metadata` post-commit rather than
/// incremented, so the emitted summary is exact even under concurrent
/// replies/deletes (newest `created_at` wins client-side).
///
/// Spawned: runs after the triggering write committed and must not add
/// latency to the ingest acknowledgement, mirroring
/// `dispatch_persistent_event`.
pub fn emit_live_thread_summary(
tenant: &TenantContext,
state: &Arc<AppState>,
channel_id: Uuid,
root_id: Vec<u8>,
) {
let tenant = tenant.clone();
let state = Arc::clone(state);
tokio::spawn(async move {
let summary = match state
.db
.get_thread_summary(tenant.community(), &root_id)
.await
{
Ok(Some(summary)) => summary,
// Root has no thread row — nothing to summarize (e.g., the root
// itself was just deleted).
Ok(None) => return,
Err(e) => {
warn!(
root = %hex::encode(&root_id),
"live thread summary lookup failed: {e}"
);
return;
}
};
let root_hex = hex::encode(&root_id);
// Same tags/content shape as the channel-window page overlay in
// `api/bridge.rs` — one contract, two delivery doors.
let content = serde_json::json!({
"reply_count": summary.reply_count,
"descendant_count": summary.descendant_count,
"last_reply_at": summary.last_reply_at.map(|t| t.timestamp()),
"participants": summary.participants.iter().map(hex::encode).collect::<Vec<_>>(),
});
let tags = [
Tag::parse(["e", &root_hex]),
Tag::parse(["d", &root_hex]),
Tag::parse(["h", &channel_id.to_string()]),
];
let mut parsed = Vec::with_capacity(tags.len());
for tag in tags {
match tag {
Ok(tag) => parsed.push(tag),
Err(e) => {
warn!(root = %root_hex, "live thread summary tag failed: {e}");
return;
}
}
}
let event = match EventBuilder::new(
Kind::Custom(KIND_THREAD_SUMMARY as u16),
content.to_string(),
)
.tags(parsed)
.sign_with_keys(&state.relay_keypair)
{
Ok(event) => event,
Err(e) => {
warn!(root = %root_hex, "live thread summary sign failed: {e}");
return;
}
};
// Redis before local fan-out so subscribers on other relay pods
// receive it too, matching `dispatch_persistent_event`.
state.mark_local_event(tenant.community(), &event.id);
if let Err(e) = state
.pubsub
.publish_event(&tenant, EventTopic::Channel(channel_id), &event)
.await
{
state
.local_event_ids
.invalidate(&(tenant.community(), event.id.to_bytes()));
warn!(root = %root_hex, "live thread summary Redis publish failed: {e}");
}
let stored = StoredEvent::new(event, Some(channel_id));
crate::handlers::event::fan_out_event_to_local_subscribers(
&state,
tenant.community(),
&stored,
)
.await;
});
}
/// Emit a relay-signed membership notification event stored globally (channel_id = None).
///
/// kind:44100 = member added, kind:44101 = member removed.
@@ -1496,6 +1600,12 @@ async fn handle_delete_event_side_effect(
return Ok(()); // No-op: skip system message to avoid false audit records.
}
// Thread counters were decremented in the same transaction — push a fresh
// relay-signed 39005 so live badge counts also count *down*.
if let Some(root_id) = root_id {
emit_live_thread_summary(tenant, state, channel_id, root_id);
}
let actor_hex = hex::encode(event.pubkey.to_bytes());
emit_system_message(
tenant,
@@ -1979,6 +2089,12 @@ async fn handle_standard_deletion_event(
continue;
}
// Thread counters were decremented in the same transaction — push a
// fresh relay-signed 39005 so live badge counts also count *down*.
if let (Some(root_id), Some(channel_id)) = (root_id, target_event.channel_id) {
emit_live_thread_summary(tenant, state, channel_id, root_id);
}
if u32::from(target_event.event.kind.as_u16()) == KIND_REACTION {
// Try by reaction_event_id first; fall back to tuple-based removal
// if the backfill was missed (set_reaction_event_id is best-effort).

View File

@@ -2128,3 +2128,93 @@ async fn test_private_channel_member_cannot_grant_admin() {
owner_client.disconnect().await.expect("disconnect owner");
member_client.disconnect().await.expect("disconnect member");
}
/// Live badge counts: every thread mutation pushes a fresh relay-signed
/// kind:39005 recount to channel subscribers — a reply counts up, deleting
/// that reply counts back down — without any window refetch.
#[tokio::test]
#[ignore]
async fn test_reply_ingest_pushes_live_thread_summary() {
let url = relay_url();
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Root message for the thread — built locally so we keep its id.
let root = EventBuilder::new(Kind::Custom(9), "thread root")
.tags([Tag::parse(["h", channel.as_str()]).unwrap()])
.sign_with_keys(&keys)
.expect("sign root");
let root_id = root.id;
let ok = client.send_event(root).await.expect("send root");
assert!(ok.accepted, "root rejected: {}", ok.message);
// Live subscription shaped like the desktop window-store one: channel
// scope with 39005 in kinds.
let sid = sub_id("live-summary");
let filter = Filter::new()
.kind(Kind::Custom(39005))
.custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE");
async fn recv_summary(client: &mut BuzzTestClient) -> nostr::Event {
loop {
match client
.recv_event(Duration::from_secs(5))
.await
.expect("recv 39005")
{
RelayMessage::Event { event, .. } if event.kind == Kind::Custom(39005) => {
return *event;
}
_ => continue,
}
}
}
// Reply → pushed summary counts up.
let h_tag = Tag::parse(["h", channel.as_str()]).unwrap();
let root_tag = Tag::parse(["e", &root_id.to_hex(), "", "reply"]).unwrap();
let reply = EventBuilder::new(Kind::Custom(9), "first reply")
.tags([h_tag, root_tag])
.sign_with_keys(&keys)
.expect("sign reply");
let reply_id = reply.id;
let ok = client.send_event(reply).await.expect("send reply");
assert!(ok.accepted, "reply rejected: {}", ok.message);
let summary = recv_summary(&mut client).await;
let root_tag_val = summary
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("e"))
.and_then(|t| t.content().map(str::to_string))
.expect("summary carries root e-tag");
assert_eq!(root_tag_val, root_id.to_hex(), "summary targets the root");
let content: serde_json::Value = serde_json::from_str(&summary.content).expect("JSON");
assert_eq!(content["reply_count"], 1, "reply counted up: {content}");
// Delete the reply → pushed summary counts back down.
let delete = EventBuilder::new(Kind::Custom(5), "")
.tags([
Tag::parse(["e", &reply_id.to_hex()]).unwrap(),
Tag::parse(["h", channel.as_str()]).unwrap(),
])
.sign_with_keys(&keys)
.expect("sign delete");
let ok = client.send_event(delete).await.expect("send delete");
assert!(ok.accepted, "delete rejected: {}", ok.message);
let summary = recv_summary(&mut client).await;
let content: serde_json::Value = serde_json::from_str(&summary.content).expect("JSON");
assert_eq!(content["reply_count"], 0, "reply counted down: {content}");
client.disconnect().await.expect("disconnect");
}

View File

@@ -40,13 +40,18 @@ import {
emptyChannelWindowStore,
flattenChannelWindowEvents,
mergeLiveChannelWindowEvent,
mergeLiveThreadSummary,
replaceNewestChannelWindow,
type ChannelWindowStore,
} from "@/features/messages/lib/channelWindowStore";
import { parseChannelWindowResponse } from "@/features/messages/lib/channelWindowResponse";
import {
parseChannelWindowResponse,
parseLiveThreadSummary,
} from "@/features/messages/lib/channelWindowResponse";
import {
CHANNEL_AUX_EVENT_KINDS,
CHANNEL_TIMELINE_CONTENT_KINDS,
KIND_CHANNEL_THREAD_SUMMARY,
KIND_STREAM_MESSAGE,
KIND_SYSTEM_MESSAGE,
} from "@/shared/constants/kinds";
@@ -319,6 +324,19 @@ export function useChannelSubscription(channel: Channel | null) {
const appendMessage = useEffectEvent((event: RelayEvent) => {
if (!channelId) return;
if (event.kind === KIND_CHANNEL_THREAD_SUMMARY) {
// Relay-pushed live badge recount — window-store overlay only, never a
// timeline row (mirrors the page path, where 39005 is metadata).
const parsed = parseLiveThreadSummary(event);
if (!parsed) return;
const windowKey = channelWindowKey(channelId);
const current =
queryClient.getQueryData<ChannelWindowStore>(windowKey) ??
emptyChannelWindowStore();
const next = mergeLiveThreadSummary(current, parsed.rootId, parsed.live);
if (next !== current) queryClient.setQueryData(windowKey, next);
return;
}
const isTimelineRow = CHANNEL_TIMELINE_KINDS.has(event.kind);
const threadReference = isTimelineRow
? getThreadReference(event.tags)

View File

@@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseChannelWindowResponse } from "./channelWindowResponse.ts";
import {
parseChannelWindowResponse,
parseLiveThreadSummary,
} from "./channelWindowResponse.ts";
function event(id, kind, createdAt, content = "", tags = []) {
return {
@@ -125,3 +128,44 @@ test("rejects absent or contradictory signed bounds", () => {
/disagree/,
);
});
test("parses a relay-pushed live thread summary", () => {
const rootId = "a".padEnd(64, "0");
const push = event(
"s",
39005,
700,
JSON.stringify({
reply_count: 4,
descendant_count: 6,
last_reply_at: 650,
participants: ["c".repeat(64)],
}),
[
["e", rootId],
["d", rootId],
],
);
const parsed = parseLiveThreadSummary(push);
assert.equal(parsed.rootId, rootId);
assert.equal(parsed.live.createdAt, 700);
assert.deepEqual(parsed.live.summary, {
replyCount: 4,
descendantCount: 6,
lastReplyAt: 650,
participantPubkeys: ["c".repeat(64)],
});
});
test("drops malformed or mistargeted live thread summaries", () => {
const rootId = "a".padEnd(64, "0");
// Wrong kind.
assert.equal(parseLiveThreadSummary(event("x", 9, 700, "hi")), null);
// No e-tag root.
assert.equal(parseLiveThreadSummary(event("s", 39005, 700, "{}")), null);
// Unparseable content only skips one refresh instead of throwing.
assert.equal(
parseLiveThreadSummary(event("s", 39005, 700, "not json", [["e", rootId]])),
null,
);
});

View File

@@ -9,6 +9,7 @@ import type {
ChannelWindowCursor,
ChannelWindowPage,
ChannelWindowThreadSummary,
LiveThreadSummary,
} from "./channelWindowStore";
const CONTENT_KINDS = new Set<number>(CHANNEL_TIMELINE_CONTENT_KINDS);
@@ -38,6 +39,38 @@ function parseJson<T>(event: RelayEvent, label: string): T {
const mapCursor = (cursor: WireCursor | null): ChannelWindowCursor | null =>
cursor ? { createdAt: cursor.created_at, eventId: cursor.id } : null;
const mapSummary = (payload: SummaryPayload): ChannelWindowThreadSummary => ({
replyCount: payload.reply_count,
descendantCount: payload.descendant_count,
lastReplyAt: payload.last_reply_at,
participantPubkeys: payload.participants,
});
/**
* Parse a relay-pushed live `39005` into its root id and summary, or null for
* anything that is not a well-formed thread summary. Live-path counterpart of
* the page parsing below — same wire contract, delivered by subscription.
*/
export function parseLiveThreadSummary(
event: RelayEvent,
): { rootId: string; live: LiveThreadSummary } | null {
if (event.kind !== KIND_CHANNEL_THREAD_SUMMARY) return null;
const rootId = targetId(event, "e");
if (!rootId) return null;
try {
return {
rootId,
live: {
summary: mapSummary(JSON.parse(event.content) as SummaryPayload),
createdAt: event.created_at,
},
};
} catch {
// A malformed live overlay only skips one badge refresh — drop it.
return null;
}
}
function expectedBoundsKey(
channelId: string,
startCursor: ChannelWindowCursor | null,
@@ -68,12 +101,7 @@ export function parseChannelWindowResponse(
const row = rootId ? rowById.get(rootId) : undefined;
if (!row) continue;
const payload = parseJson<SummaryPayload>(event, "thread summary");
row.thread = {
replyCount: payload.reply_count,
descendantCount: payload.descendant_count,
lastReplyAt: payload.last_reply_at,
participantPubkeys: payload.participants,
};
row.thread = mapSummary(payload);
}
const boundsEvents = events.filter(

View File

@@ -2,9 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
appendOlderChannelWindow,
channelWindowThreadSummaries,
emptyChannelWindowStore,
flattenChannelWindowEvents,
mergeLiveChannelWindowEvent,
mergeLiveThreadSummary,
replaceNewestChannelWindow,
} from "./channelWindowStore.ts";
@@ -269,3 +271,74 @@ test("flattening dedupes aux closure events returned on adjacent pages", () => {
1,
);
});
const summary = (replyCount) => ({
replyCount,
descendantCount: replyCount,
lastReplyAt: 500,
participantPubkeys: ["c".repeat(64)],
});
const live = (replyCount, createdAt) => ({
summary: summary(replyCount),
createdAt,
});
test("live thread summary overlays the page summary for its root", () => {
const root = event("a", 100);
const first = page(null, [root], { hasMore: false });
first.rows[0].thread = summary(1);
let store = replaceNewestChannelWindow(emptyChannelWindowStore(), first);
store = mergeLiveThreadSummary(store, root.id, live(2, 200));
assert.equal(channelWindowThreadSummaries(store).get(root.id).replyCount, 2);
});
test("live thread summary gives a badge to roots with no page summary", () => {
const store = mergeLiveThreadSummary(
replaceNewestChannelWindow(
emptyChannelWindowStore(),
page(null, [event("a", 100)], { hasMore: false }),
),
event("a", 100).id,
live(1, 200),
);
const summaries = channelWindowThreadSummaries(store);
assert.equal(summaries.get(event("a", 100).id).replyCount, 1);
});
test("stale live thread summaries never roll a newer one back", () => {
const rootId = event("a", 100).id;
const store = mergeLiveThreadSummary(
emptyChannelWindowStore(),
rootId,
live(3, 300),
);
// Same-timestamp and older pushes are both ignored; identity proves no churn.
assert.equal(mergeLiveThreadSummary(store, rootId, live(2, 300)), store);
assert.equal(mergeLiveThreadSummary(store, rootId, live(2, 250)), store);
assert.equal(channelWindowThreadSummaries(store).get(rootId).replyCount, 3);
});
test("head refetch clears live summaries in favor of the fresh snapshot", () => {
const root = event("a", 100);
let store = mergeLiveThreadSummary(
emptyChannelWindowStore(),
root.id,
live(7, 200),
);
const refreshed = page(null, [root], { hasMore: false });
refreshed.rows[0].thread = summary(4);
store = replaceNewestChannelWindow(store, refreshed);
assert.equal(channelWindowThreadSummaries(store).get(root.id).replyCount, 4);
});
test("live summaries survive scrollback pages and still win for their root", () => {
const head = event("b", 200);
const older = event("a", 100);
const first = page(null, [head]);
let store = replaceNewestChannelWindow(emptyChannelWindowStore(), first);
store = mergeLiveThreadSummary(store, older.id, live(5, 300));
const tail = page(first.nextCursor, [older], { hasMore: false });
tail.rows[0].thread = summary(4);
store = appendOlderChannelWindow(store, tail);
assert.equal(channelWindowThreadSummaries(store).get(older.id).replyCount, 5);
});

View File

@@ -11,6 +11,11 @@ export type ChannelWindowRow = {
event: RelayEvent;
thread: ChannelWindowThreadSummary | null;
};
export type LiveThreadSummary = {
summary: ChannelWindowThreadSummary;
/** `created_at` of the relay 39005 that carried it — newest wins per root. */
createdAt: number;
};
export type ChannelWindowPage = {
startCursor: ChannelWindowCursor | null;
rows: ChannelWindowRow[];
@@ -24,12 +29,18 @@ export type ChannelWindowStore = {
liveOverlay: RelayEvent[];
/** Live structural events retained independently from frozen page closure. */
liveAux: RelayEvent[];
/**
* Relay-pushed 39005 summaries (keyed by thread root id) not yet superseded
* by a page. A page response supersedes them for the roots it re-delivers.
*/
liveSummaries: Record<string, LiveThreadSummary>;
};
export const emptyChannelWindowStore = (): ChannelWindowStore => ({
pages: [],
liveOverlay: [],
liveAux: [],
liveSummaries: {},
});
function cursorsEqual(
@@ -103,6 +114,10 @@ export function replaceNewestChannelWindow(
pages: [page],
liveOverlay: current.liveOverlay.filter((event) => !ids.has(event.id)),
liveAux: current.liveAux.filter((event) => !auxIds.has(event.id)),
// A head refetch is the authoritative resync moment (subscribe/reconnect
// both funnel here): every retained page is replaced, so live summaries
// pinned to the old snapshot are cleared rather than diffed.
liveSummaries: {},
};
}
@@ -140,6 +155,27 @@ export function appendOlderChannelWindow(
};
}
/**
* Record a relay-pushed live `39005` summary. Newest `created_at` wins per
* root: the relay pushes a full recount on every thread mutation, so the
* latest push is authoritative for that root — including counting *down*
* after a delete. Retained across scrollback pages (a racing push can be
* fresher than a just-fetched page summary) and cleared only by the head
* refetch in `replaceNewestChannelWindow`.
*/
export function mergeLiveThreadSummary(
current: ChannelWindowStore,
rootId: string,
incoming: LiveThreadSummary,
): ChannelWindowStore {
const existing = current.liveSummaries[rootId];
if (existing && existing.createdAt >= incoming.createdAt) return current;
return {
...current,
liveSummaries: { ...current.liveSummaries, [rootId]: incoming },
};
}
/**
* Merge a live top-level event without mutating authoritative page boundaries.
* Events below the oldest loaded boundary wait for ordinary relay pagination.
@@ -205,12 +241,22 @@ export function channelWindowHasMore(store: ChannelWindowStore) {
return tail?.hasMore ?? false;
}
/**
* Per-root thread summaries for badge rendering: authoritative page summaries
* overlaid with any fresher relay-pushed live summaries. The live overlay also
* covers roots that reached the screen outside a page (liveOverlay rows,
* refetch-reconciled rows), which have no page summary at all.
*/
export function channelWindowThreadSummaries(store: ChannelWindowStore) {
return new Map(
const summaries = new Map(
store.pages.flatMap((page) =>
page.rows.flatMap((row) =>
row.thread ? ([[row.event.id, row.thread]] as const) : [],
),
),
);
for (const [rootId, live] of Object.entries(store.liveSummaries)) {
summaries.set(rootId, live.summary);
}
return summaries;
}

View File

@@ -11,6 +11,7 @@ import {
KIND_TYPING_INDICATOR,
KIND_USER_STATUS,
CHANNEL_EVENT_KINDS,
KIND_CHANNEL_THREAD_SUMMARY,
} from "@/shared/constants/kinds";
import {
getTextPayload,
@@ -340,7 +341,10 @@ export class RelayClient {
) {
return this.subscribe(
{
kinds: [...CHANNEL_EVENT_KINDS],
// 39005 rides only this window-store subscription — not
// CHANNEL_EVENT_KINDS, whose other consumers (unread tracking,
// timeline-cache merges) must never see summary overlays.
kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY],
"#h": [channelId],
limit: 1000,
since: Math.floor(Date.now() / 1_000),