Skip to content

Commit 031df39

Browse files
fix(desktop): hydrate notification replies before selecting threads
Signed-off-by: John R. Enders <92937551+JohnRichardEnders@users.noreply.github.com>
1 parent 3c7f288 commit 031df39

2 files changed

Lines changed: 74 additions & 18 deletions

File tree

desktop/src/app/routes/ChannelRouteScreen.tsx

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,7 @@ export function ChannelRouteScreen({
161161
enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null;
162162
const [targetMessageEvents, setTargetMessageEvents] = React.useState<
163163
RelayEvent[]
164-
>(() => {
165-
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
166-
return cachedTarget ? [cachedTarget] : [];
167-
});
164+
>([]);
168165
const [activeSearchHighlight, setActiveSearchHighlight] =
169166
React.useState<SearchHighlightNavigation | null>(searchHighlight ?? null);
170167
const appliedSearchActivationIdRef = React.useRef<string | null>(
@@ -211,13 +208,8 @@ export function ChannelRouteScreen({
211208
targetReplyId,
212209
targetThreadRootId,
213210
]);
214-
215-
// Reset spliced target events when the channel changes. Tied to channel
216-
// identity rather than the route target so clearing the `messageId` param
217-
// mid-channel keeps the deep-linked row in view. Seeded with the mount key so
218-
// the initial cache-seeded events survive first commit; only a genuine
219-
// channel change clears them. Declared before the fetch effect so a channel
220-
// switch clears stale events before the new target is fetched.
211+
// Keep spliced events after the route target clears so a deep-linked row
212+
// remains visible, but discard them when switching channels.
221213
const previousResetKeyRef = React.useRef<string>(channelId);
222214
React.useEffect(() => {
223215
if (previousResetKeyRef.current === channelId) return;
@@ -243,13 +235,9 @@ export function ChannelRouteScreen({
243235
}
244236

245237
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
246-
if (cachedTarget) {
247-
setTargetMessageEvents((currentEvents) =>
248-
currentEvents.some((event) => event.id === cachedTarget.id)
249-
? currentEvents
250-
: [...currentEvents, cachedTarget],
251-
);
252-
}
238+
// Search/notification projections have no reply tags. Inserting one before
239+
// hydration can select the reply as a root and consume the route target.
240+
// Retain it only as a fallback after the authoritative lookup completes.
253241

254242
const eventIds = [
255243
targetMessageId,
@@ -269,6 +257,9 @@ export function ChannelRouteScreen({
269257
for (const event of [...currentEvents, ...events]) {
270258
eventsById.set(event.id, event);
271259
}
260+
if (cachedTarget && !eventsById.has(cachedTarget.id)) {
261+
eventsById.set(cachedTarget.id, cachedTarget);
262+
}
272263
return Array.from(eventsById.values());
273264
});
274265
}

desktop/tests/e2e/navigation.spec.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,3 +1058,68 @@ test("cold-start message deep link preserves its thread target", async ({
10581058
await expect(page).toHaveURL(/messageId=mock-forum-release-reply/);
10591059
await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/);
10601060
});
1061+
1062+
test("reply notification hydration opens the full thread instead of the cached reply", async ({
1063+
page,
1064+
}) => {
1065+
await page.goto("/");
1066+
await page.getByTestId("channel-random").click();
1067+
await expect(page.getByTestId("message-input")).toBeVisible();
1068+
const targetId = await page.evaluate(() => {
1069+
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
1070+
if (!emit) throw new Error("Mock message fixture unavailable");
1071+
const input = {
1072+
channelName: "engineering",
1073+
pubkey:
1074+
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
1075+
};
1076+
const root = emit({ ...input, content: "Notification context root" });
1077+
emit({
1078+
...input,
1079+
parentEventId: root.id,
1080+
content: "Sibling reply retains context",
1081+
});
1082+
const parent = emit({
1083+
...input,
1084+
parentEventId: root.id,
1085+
content: "Parent reply retains context",
1086+
});
1087+
const target = emit({
1088+
...input,
1089+
parentEventId: parent.id,
1090+
content: "Nested notification reply",
1091+
});
1092+
window.__BUZZ_E2E_DEFER_GET_EVENT__ = target.id;
1093+
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
1094+
category: "mention",
1095+
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
1096+
channel_name: "engineering",
1097+
content: target.content,
1098+
created_at: Math.floor(Date.now() / 1000) + 5,
1099+
id: target.id,
1100+
kind: target.kind,
1101+
pubkey: target.pubkey,
1102+
tags: target.tags.concat([["p", "deadbeef".repeat(8)]]),
1103+
});
1104+
return target.id;
1105+
});
1106+
await expect
1107+
.poll(() =>
1108+
page.evaluate(() => window.__BUZZ_E2E_NOTIFICATIONS__?.length ?? 0),
1109+
)
1110+
.toBe(1);
1111+
await page.evaluate(() => window.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0));
1112+
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
1113+
// Hold the authoritative lookup until navigation has committed. The cached
1114+
// notification has no reply tags and must not become the selected thread.
1115+
await page.evaluate(() => window.__BUZZ_E2E_RELEASE_GET_EVENT__?.());
1116+
const thread = page.getByTestId("message-thread-panel");
1117+
await expect(thread.getByTestId("message-thread-head")).toContainText(
1118+
"Notification context root",
1119+
);
1120+
await expect(thread).toContainText("Sibling reply retains context");
1121+
await expect(thread).toContainText("Parent reply retains context");
1122+
await expect(thread.locator(`[data-message-id="${targetId}"]`)).toContainText(
1123+
"Nested notification reply",
1124+
);
1125+
});

0 commit comments

Comments
 (0)