Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions desktop/src/features/channels/useLiveChannelUpdates.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,53 @@ test("unmount disposes both established and pending channel streams", async () =
h.restore();
}
});

test("live threaded reply routes to its thread cache, never the channel timeline cache", async () => {
const h = await mount(channels(2));
try {
const { threadRepliesKey } = await import(
"@/features/messages/lib/messageQueryKeys"
);
const key = h.channelMessagesKey("channel-1");
h.queryClient.setQueryData(key, [
message("root", { tags: [["h", "channel-1"]] }),
]);
// Same tag shape the relay holds for a `buzz messages send --reply-to`
// reply: h + marked e + p, no broadcast.
const replyTags = [
["h", "channel-1"],
["e", "root", "", "reply"],
["p", PEER],
];
await h.deliver(
h.subscriptions[1],
message("reply", { content: "threaded", tags: replyTags }),
);
assert.deepEqual(
h.queryClient.getQueryData(key).map((event) => event.id),
["root"],
"a non-broadcast reply must not enter the channel timeline cache",
);
assert.deepEqual(
h.queryClient
.getQueryData(threadRepliesKey("channel-1", "root"))
.map((event) => event.id),
["reply"],
);

await h.deliver(
h.subscriptions[1],
message("shout", {
content: "also to channel",
tags: [...replyTags, ["broadcast", "1"]],
}),
);
assert.deepEqual(
h.queryClient.getQueryData(key).map((event) => event.id),
["root", "shout"],
"a broadcast reply still lands in the channel timeline cache",
);
} finally {
h.restore();
}
});
26 changes: 24 additions & 2 deletions desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ import { useQueryClient } from "@tanstack/react-query";

import { channelsQueryKey } from "@/features/channels/hooks";
import { updateChannelLastMessageAt } from "@/features/channels/lib/channelRecency";
import { mergeTimelineCacheMessages } from "@/features/messages/hooks";
import { channelMessagesKey } from "@/features/messages/lib/messageQueryKeys";
import {
mergeMessages,
mergeTimelineCacheMessages,
} from "@/features/messages/hooks";
import {
channelMessagesKey,
threadRepliesKey,
} from "@/features/messages/lib/messageQueryKeys";
import {
getChannelIdFromTags,
getThreadReference,
isThreadReply,
} from "@/features/messages/lib/threading";
import {
Expand Down Expand Up @@ -335,6 +342,21 @@ export function useLiveChannelUpdates(
}
}

// Route the event the same way useChannelSubscription's appendMessage
// does: a non-broadcast thread reply belongs to its root's thread cache,
// never the flat channel timeline. Merging it there renders it as a
// top-level row until the next window projection drops it (#7705).
if (isThreadedReply) {
const rootId = getThreadReference(event.tags).rootId;
if (rootId) {
queryClient.setQueryData<RelayEvent[]>(
threadRepliesKey(channelId, rootId),
(current = []) => mergeMessages(current, event),
);
}
return;
}

// Merge into the timeline cache for the active channel.
// useChannelSubscription also writes to this cache, but there's a
// race window where it hasn't connected yet. Writes are idempotent
Expand Down
Loading