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
27 changes: 9 additions & 18 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,7 @@ export function ChannelRouteScreen({
enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null;
const [targetMessageEvents, setTargetMessageEvents] = React.useState<
RelayEvent[]
>(() => {
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
>([]);
const [activeSearchHighlight, setActiveSearchHighlight] =
React.useState<SearchHighlightNavigation | null>(searchHighlight ?? null);
const appliedSearchActivationIdRef = React.useRef<string | null>(
Expand Down Expand Up @@ -211,13 +208,8 @@ export function ChannelRouteScreen({
targetReplyId,
targetThreadRootId,
]);

// Reset spliced target events when the channel changes. Tied to channel
// identity rather than the route target so clearing the `messageId` param
// mid-channel keeps the deep-linked row in view. Seeded with the mount key so
// the initial cache-seeded events survive first commit; only a genuine
// channel change clears them. Declared before the fetch effect so a channel
// switch clears stale events before the new target is fetched.
// Keep spliced events after the route target clears so a deep-linked row
// remains visible, but discard them when switching channels.
const previousResetKeyRef = React.useRef<string>(channelId);
React.useEffect(() => {
if (previousResetKeyRef.current === channelId) return;
Expand All @@ -243,13 +235,9 @@ export function ChannelRouteScreen({
}

const cachedTarget = getCachedSearchHitEvent(targetMessageId);
if (cachedTarget) {
setTargetMessageEvents((currentEvents) =>
currentEvents.some((event) => event.id === cachedTarget.id)
? currentEvents
: [...currentEvents, cachedTarget],
);
}
// Search/notification projections have no reply tags. Inserting one before
// hydration can select the reply as a root and consume the route target.
// Retain it only as a fallback after the authoritative lookup completes.

const eventIds = [
targetMessageId,
Expand All @@ -269,6 +257,9 @@ export function ChannelRouteScreen({
for (const event of [...currentEvents, ...events]) {
eventsById.set(event.id, event);
}
if (cachedTarget && !eventsById.has(cachedTarget.id)) {
eventsById.set(cachedTarget.id, cachedTarget);
}
return Array.from(eventsById.values());
});
}
Expand Down
65 changes: 65 additions & 0 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1058,3 +1058,68 @@ test("cold-start message deep link preserves its thread target", async ({
await expect(page).toHaveURL(/messageId=mock-forum-release-reply/);
await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/);
});

test("reply notification hydration opens the full thread instead of the cached reply", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("message-input")).toBeVisible();
const targetId = await page.evaluate(() => {
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) throw new Error("Mock message fixture unavailable");
const input = {
channelName: "engineering",
pubkey:
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f",
};
const root = emit({ ...input, content: "Notification context root" });
emit({
...input,
parentEventId: root.id,
content: "Sibling reply retains context",
});
const parent = emit({
...input,
parentEventId: root.id,
content: "Parent reply retains context",
});
const target = emit({
...input,
parentEventId: parent.id,
content: "Nested notification reply",
});
window.__BUZZ_E2E_DEFER_GET_EVENT__ = target.id;
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
category: "mention",
channel_id: "1c7e1c02-87bb-5e88-b2da-5a7a9432d0c9",
channel_name: "engineering",
content: target.content,
created_at: Math.floor(Date.now() / 1000) + 5,
id: target.id,
kind: target.kind,
pubkey: target.pubkey,
tags: target.tags.concat([["p", "deadbeef".repeat(8)]]),
});
return target.id;
});
await expect
.poll(() =>
page.evaluate(() => window.__BUZZ_E2E_NOTIFICATIONS__?.length ?? 0),
)
.toBe(1);
await page.evaluate(() => window.__BUZZ_E2E_CLICK_NOTIFICATION__?.(0));
await expect(page.getByTestId("chat-title")).toHaveText("engineering");
// Hold the authoritative lookup until navigation has committed. The cached
// notification has no reply tags and must not become the selected thread.
await page.evaluate(() => window.__BUZZ_E2E_RELEASE_GET_EVENT__?.());
const thread = page.getByTestId("message-thread-panel");
await expect(thread.getByTestId("message-thread-head")).toContainText(
"Notification context root",
);
await expect(thread).toContainText("Sibling reply retains context");
await expect(thread).toContainText("Parent reply retains context");
await expect(thread.locator(`[data-message-id="${targetId}"]`)).toContainText(
"Nested notification reply",
);
});