Skip to content
Merged
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
17 changes: 16 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ import {
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
import { Button } from "@/shared/ui/button";
import type { useChannelFind } from "@/features/search/useChannelFind";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import {
buildMainTimelineEntries,
type MainTimelineEntry,
} from "@/features/messages/lib/threadPanel";
import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration";
import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { isWelcomeChannel } from "@/features/onboarding/welcome";
Expand Down Expand Up @@ -563,6 +567,16 @@ export const ChannelPane = React.memo(function ChannelPane({

return messages.filter((message) => !isWelcomeSetupSystemMessage(message));
}, [activeChannel, messages]);
const mainTimelineEntries = React.useMemo(
() => buildMainTimelineEntries(visibleMessages),
[visibleMessages],
);
useRenderScopedReactionHydration({
activeChannel,
mainTimelineEntries,
threadHeadMessage,
threadMessages,
});
const videoReviewCommentsByRootId = React.useMemo(
() => buildVideoReviewCommentsByRootId(messages),
[messages],
Expand Down Expand Up @@ -677,6 +691,7 @@ export const ChannelPane = React.memo(function ChannelPane({
: "No channel selected"
}
isLoading={isTimelineLoading}
mainEntries={mainTimelineEntries}
messages={visibleMessages}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
Expand Down
20 changes: 11 additions & 9 deletions desktop/src/features/messages/lib/auxBackfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelStructuralAuxFilter } from "@/shared/api/relayChannelFilters";
import type { RelayEvent } from "@/shared/api/types";
import {
CHANNEL_TIMELINE_CONTENT_KINDS,
Expand Down Expand Up @@ -65,16 +66,16 @@ export async function mergeAuxEventsWithDeletionBackfill(input: {
}

/**
* After a content-kinds-only history fetch, pull the auxiliary events
* (reactions, edits, deletions) that reference the loaded messages — keyed by
* `#e` over their ids, not by a time window — and merge them into the same
* channel cache.
* After a content-kinds-only history fetch, pull structural auxiliary events
* (edits/deletions) that reference the loaded messages — keyed by `#e` over
* their ids, not by a time window — and merge them into the same channel cache.
* Reactions are hydrated separately for the rows the GUI renders.
*
* History fetches request content kinds only so the `limit` budget buys
* visible message depth (a reaction-heavy 200-event window was only ~136
* messages). The cost is that an edit/deletion for a visible message can fall
* outside any fetched time window — so aux must be pulled by reference, or a
* visible message renders stale (un-edited / not-deleted).
* visible message depth. The cost is that an edit/deletion for a visible
* message can fall outside any fetched time window — so structural aux must be
* pulled by reference, or a visible message renders stale (un-edited /
* not-deleted).
*
* Best-effort: failures are logged but never reject, so a flaky overlay fetch
* can't blank the freshly-loaded messages.
Expand All @@ -92,9 +93,10 @@ export async function backfillAuxForMessages(
try {
const cacheKey = channelMessagesKey(channelId);
const cachedEvents = queryClient.getQueryData<RelayEvent[]>(cacheKey) ?? [];
const auxEvents = await relayClient.fetchAuxEventsForMessages(
const auxEvents = await relayClient.fetchAuxEventsByReference(
channelId,
messageIds,
buildChannelStructuralAuxFilter,
);
const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({
channelId,
Expand Down
176 changes: 176 additions & 0 deletions desktop/src/features/messages/lib/renderScopedReactions.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
claimUnhydratedRenderScopedReactionIds,
collectRenderScopedReactionMessageIds,
hydrateRenderScopedReactions,
releaseRenderScopedReactionIds,
resetRenderScopedReactionHydration,
} from "./renderScopedReactions.ts";
import { formatTimelineMessages } from "./formatTimelineMessages.ts";
import { channelMessagesKey } from "./messageQueryKeys.ts";

const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";

function hex(char) {
return char.repeat(64);
}

function event(id, kind, overrides = {}) {
return {
id,
pubkey: hex("a"),
kind,
created_at: 1_700_000_000,
content: "",
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}

function entry(message) {
return { message, summary: null };
}

function makeQueryClientStub(initialEvents = []) {
const store = new Map([
[JSON.stringify(channelMessagesKey(CHANNEL_ID)), initialEvents],
]);
return {
getQueryData(key) {
return store.get(JSON.stringify(key));
},
setQueryData(key, updater) {
const k = JSON.stringify(key);
const next =
typeof updater === "function" ? updater(store.get(k) ?? []) : updater;
store.set(k, next);
return next;
},
};
}

test.afterEach(() => {
resetRenderScopedReactionHydration();
});

test("collects main timeline and open thread messages without hidden replies", () => {
const main = event(hex("1"), 9);
const hiddenCollapsedReply = event(hex("2"), 9, {
tags: [["e", main.id]],
});
const threadHead = event(hex("3"), 9);
const visibleThreadReply = event(hex("4"), 9, {
tags: [["e", threadHead.id]],
});

assert.deepEqual(
collectRenderScopedReactionMessageIds({
mainEntries: [entry(main), entry(threadHead)],
threadHeadMessage: threadHead,
threadEntries: [entry(visibleThreadReply)],
}),
[main.id, threadHead.id, visibleThreadReply.id],
);

assert.ok(
!collectRenderScopedReactionMessageIds({
mainEntries: [entry(main), entry(threadHead)],
threadHeadMessage: threadHead,
threadEntries: [entry(visibleThreadReply)],
}).includes(hiddenCollapsedReply.id),
);
});

test("claims each rendered message id once per channel and can retry released ids", () => {
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [
hex("1"),
hex("2"),
hex("1"),
]),
[hex("1"), hex("2")],
);
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]),
[],
);
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds("other-channel", [hex("1")]),
[hex("1")],
);

releaseRenderScopedReactionIds(CHANNEL_ID, [hex("2")]);
assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]),
[hex("2")],
);
});

test("hydrates visible reactions into the channel timeline cache", async () => {
const messageId = hex("1");
const reactionId = hex("2");
const currentUser = hex("c");
const message = event(messageId, 9, {
pubkey: hex("a"),
content: "ship it?",
});
const reaction = event(reactionId, 7, {
pubkey: currentUser,
content: "✅",
tags: [["e", messageId]],
});
const queryClient = makeQueryClientStub([message]);
const calls = [];

await hydrateRenderScopedReactions({
channelId: CHANNEL_ID,
messageIds: [messageId],
queryClient,
deps: {
fetchReactionEventsForMessages: async (channelId, messageIds) => {
calls.push({ channelId, messageIds });
return [reaction];
},
},
});

assert.deepEqual(calls, [{ channelId: CHANNEL_ID, messageIds: [messageId] }]);
const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID));
assert.ok(cached.some((e) => e.id === reactionId));

const timeline = formatTimelineMessages(cached, null, currentUser, null);
assert.deepEqual(
timeline
.find((m) => m.id === messageId)
?.reactions?.map((r) => ({
count: r.count,
emoji: r.emoji,
mine: r.reactedByCurrentUser,
})),
[{ count: 1, emoji: "✅", mine: true }],
);
});

test("failed hydration releases ids so the next render can retry", async () => {
const messageId = hex("1");
const queryClient = makeQueryClientStub([event(messageId, 9)]);

await hydrateRenderScopedReactions({
channelId: CHANNEL_ID,
messageIds: [messageId],
queryClient,
deps: {
fetchReactionEventsForMessages: async () => {
throw new Error("relay timeout");
},
},
});

assert.deepEqual(
claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [messageId]),
[messageId],
);
});
Loading
Loading