From 9d7623b3b56abfd2cf263fc47c3f20ba6fc2f0c2 Mon Sep 17 00:00:00 2001 From: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Date: Sat, 22 Aug 2026 11:40:53 -0400 Subject: [PATCH] perf(threads): collapse reply reads and sends Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/api/bridge.rs | 67 ++++++++++++++++-- desktop/src-tauri/src/commands/messages.rs | 68 +++++++++---------- .../src/commands/messages/thread_ref.rs | 34 +++++++++- .../src-tauri/src/commands/messages_tests.rs | 12 ++++ desktop/src/features/messages/hooks.ts | 28 +++++++- .../messages/lib/sendChannelBinding.test.mjs | 21 ++++++ .../messages/useThreadReplies.test.mjs | 35 +++------- .../src/features/messages/useThreadReplies.ts | 67 +----------------- desktop/src/shared/api/tauriMessages.ts | 2 + desktop/src/testing/e2eBridge.ts | 14 ++-- 10 files changed, 214 insertions(+), 134 deletions(-) diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 8fdea4b3c0..ee4bd08162 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -393,6 +393,18 @@ const WINDOW_AUX_DELETE_KINDS: [u32; 2] = [ buzz_core::kind::KIND_NIP29_DELETE_EVENT, ]; +fn build_aux_query( + community: buzz_core::CommunityId, + target_ids: Vec, + 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.limit = Some(1000); + query +} + /// 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,10 +508,8 @@ 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_query = + build_aux_query(tenant.community(), std::mem::take(&mut hop_ids), hop_kinds); let aux_events = session .query_events(&aux_query) .await @@ -1203,6 +1213,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 +1226,43 @@ 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 = state + .db + .query_events_routed("bridge_thread_aux", &aux_query) + .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 +2418,20 @@ mod tests { assert!(!has_mixed_search_filters(&filters)); } + #[test] + fn thread_aux_query_targets_root_and_replies_with_full_first_hop() { + 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, Some(1000)); + } + #[test] fn bridge_search_mode_extension_defaults_to_full_text() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 31559777d2..461f29e7fa 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -236,22 +236,11 @@ fn search_messages_limit(limit: Option) -> 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, + root_event_id: Option, media_tags: Option>>, emoji_tags: Option>>, mention_tags: Option>>, @@ -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 = 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) } diff --git a/desktop/src-tauri/src/commands/messages/thread_ref.rs b/desktop/src-tauri/src/commands/messages/thread_ref.rs index 97a03fdad5..8ec82beebb 100644 --- a/desktop/src-tauri/src/commands/messages/thread_ref.rs +++ b/desktop/src-tauri/src/commands/messages/thread_ref.rs @@ -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 { + 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 { + 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 — diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index c0ad03d936..dc7c0f4b5a 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -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()); +} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index ff022ab522..63db15cbe4 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -90,6 +90,18 @@ type MessageQueryContext = { const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); const CHANNEL_AUX_KINDS = new Set(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, @@ -551,6 +563,17 @@ export function useSendMessageMutation( queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), ) ?? []; + const threadCaches = queryClient + .getQueriesData({ + queryKey: ["thread-replies", effectiveChannel.id], + }) + .flatMap(([, events]) => (events ? [events] : [])); + const suppliedRootEventId = parentEventId + ? resolveCachedReplyRootId(parentEventId, [ + cachedMessages, + ...threadCaches, + ]) + : null; const result = await sendChannelMessage( effectiveChannel.id, content, @@ -562,6 +585,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. @@ -572,7 +598,7 @@ export function useSendMessageMutation( effectiveChannel.id, identity.pubkey, parentEventId, - resolveReplyRootId(parentEventId, cachedMessages), + result.rootEventId, recipientPubkeys, ) : []; diff --git a/desktop/src/features/messages/lib/sendChannelBinding.test.mjs b/desktop/src/features/messages/lib/sendChannelBinding.test.mjs index 421799d373..bfe66afcff 100644 --- a/desktop/src/features/messages/lib/sendChannelBinding.test.mjs +++ b/desktop/src/features/messages/lib/sendChannelBinding.test.mjs @@ -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); +}); diff --git a/desktop/src/features/messages/useThreadReplies.test.mjs b/desktop/src/features/messages/useThreadReplies.test.mjs index 48896c5c51..53399cd2f3 100644 --- a/desktop/src/features/messages/useThreadReplies.test.mjs +++ b/desktop/src/features/messages/useThreadReplies.test.mjs @@ -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\)/); }); diff --git a/desktop/src/features/messages/useThreadReplies.ts b/desktop/src/features/messages/useThreadReplies.ts index bb1c2909b6..4b7d7338ac 100644 --- a/desktop/src/features/messages/useThreadReplies.ts +++ b/desktop/src/features/messages/useThreadReplies.ts @@ -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, -): Promise { - 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 { - 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(queryKey) ?? []; const receivedInFlight = current.filter( (event) => !idsAtStart.has(event.id), ); - return sortMessages([...fetched, ...receivedInFlight]); + return sortMessages([...replies, ...receivedInFlight]); } cursor = response.nextCursor; } @@ -123,7 +62,7 @@ export function useThreadReplies( if (!activeChannel || !openThreadRootId) return []; return loadThreadReplies(queryClient, activeChannel.id, openThreadRootId); }, - staleTime: 0, + staleTime: 30_000, gcTime: 60 * 60 * 1_000, }); } @@ -145,7 +84,7 @@ export function useThreadRepliesForRoots( queryKey: threadRepliesKey(channelId, rootId), enabled: activeChannel !== null && activeChannel.channelType !== "forum", queryFn: () => loadThreadReplies(queryClient, channelId, rootId), - staleTime: 0, + staleTime: 30_000, gcTime: 60 * 60 * 1_000, })), combine: (results) => ({ diff --git a/desktop/src/shared/api/tauriMessages.ts b/desktop/src/shared/api/tauriMessages.ts index 4abe03ee09..965e498b41 100644 --- a/desktop/src/shared/api/tauriMessages.ts +++ b/desktop/src/shared/api/tauriMessages.ts @@ -15,6 +15,7 @@ export async function sendChannelMessage( sentFromThreadTag?: string[], expectedRelayUrl?: string, expectedSignerPubkey?: string, + rootEventId?: string | null, ): Promise { const response = await invokeTauri( "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, diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 63f3a4ba38..56e18e23aa 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9577,6 +9577,7 @@ async function handleSendChannelMessage( channelId: string; content: string; parentEventId?: string | null; + rootEventId?: string | null; kind?: number | null; mentionPubkeys?: string[]; mediaTags?: string[][] | null; @@ -9699,7 +9700,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 +9760,7 @@ async function handleSendChannelMessage( args.channelId, relayIdentity.pubkey, args.parentEventId, - args.parentEventId, + args.rootEventId ?? args.parentEventId, args.mentionPubkeys, ) : buildTopLevelMessageTags( @@ -9776,8 +9778,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), }; }