From 1b207bac111275dff70ec0c1ef4028dee0d98c81 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 22 Aug 2026 21:15:52 -0700 Subject: [PATCH] fix(desktop): guard same-thread message navigation Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../features/channels/ui/ChannelScreen.tsx | 6 +- .../ui/useMessageLinkNavigationGuard.ts | 21 +--- desktop/tests/e2e/messaging.spec.ts | 99 +++++++++++++++++++ 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index b6737e8acc..9818ae6fa6 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -661,11 +661,7 @@ export function ChannelScreen({ setThreadReplyTargetId(null); setEditTargetId(null); }, [activeChannelId]); - useMessageLinkNavigationGuard( - requireThreadEditResolution, - activeChannelId, - effectiveOpenThreadHeadId, - ); + useMessageLinkNavigationGuard(requireThreadEditResolution); const mainTimelineTargetMessageId = useChannelRouteTarget({ activeChannel, activeChannelId, diff --git a/desktop/src/features/channels/ui/useMessageLinkNavigationGuard.ts b/desktop/src/features/channels/ui/useMessageLinkNavigationGuard.ts index a49bcd0f37..68b5c83c66 100644 --- a/desktop/src/features/channels/ui/useMessageLinkNavigationGuard.ts +++ b/desktop/src/features/channels/ui/useMessageLinkNavigationGuard.ts @@ -1,28 +1,13 @@ import * as React from "react"; -import type { MessageTargetNavigation } from "@/app/navigation/messageTargetNavigationGuard"; import { registerMessageTargetNavigationGuard } from "@/app/navigation/messageTargetNavigationGuard"; export function useMessageLinkNavigationGuard( requireThreadEditResolution: () => boolean, - activeChannelId: string | null, - openThreadHeadId: string | null, ) { - const guard = React.useCallback( - (target: MessageTargetNavigation) => { - if ( - target.kind === "channel-message" && - target.channelId === activeChannelId && - target.threadRootId === openThreadHeadId - ) { - return true; - } - return requireThreadEditResolution(); - }, - [activeChannelId, openThreadHeadId, requireThreadEditResolution], - ); React.useLayoutEffect( - () => registerMessageTargetNavigationGuard(guard), - [guard], + () => + registerMessageTargetNavigationGuard(() => requireThreadEditResolution()), + [requireThreadEditResolution], ); } diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index b7bc97b6cd..5d3c6445a7 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3914,6 +3914,105 @@ test("a refused forum search result preserves the edit and retries after cancel" ).toContainText("Release checklist: async feedback thread."); }); +for (const targetKind of ["reply", "root"] as const) { + test(`a refused same-thread ${targetKind} target preserves the edit and retries after cancel`, async ({ + page, + }) => { + const sourceRoot = `Same-thread ${targetKind} guard root ${Date.now()}`; + const sourceReply = `Same-thread ${targetKind} guard reply ${Date.now()}`; + const dirtyReply = `${sourceReply} unsaved byte-for-byte 🧵`; + + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { sourceReplyId, sourceRootId } = await page.evaluate( + ({ sourceReply, sourceRoot, targetKind }) => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const root = emit({ channelName: "general", content: sourceRoot }); + const reply = emit({ + channelName: "general", + content: sourceReply, + parentEventId: root.id, + }); + const targetId = targetKind === "reply" ? reply.id : root.id; + emit({ + channelName: "general", + content: `Same-thread ${targetKind} target buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${targetId}&thread=${root.id}`, + }); + return { sourceReplyId: reply.id, sourceRootId: root.id }; + }, + { sourceReply, sourceRoot, targetKind }, + ); + + await page.getByTestId("channel-general").click(); + const timeline = page.getByTestId("message-timeline"); + const source = timeline.locator(`[data-message-id="${sourceRootId}"]`); + await source.hover(); + await source.getByRole("button", { name: "Reply" }).click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadInput = threadPanel.getByTestId("message-input"); + const reply = threadPanel.locator(`[data-message-id="${sourceReplyId}"]`); + await reply.hover(); + await reply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(dirtyReply); + + const targetLink = timeline + .getByTestId("message-row") + .filter({ hasText: `Same-thread ${targetKind} target` }) + .getByRole("button", { name: "Open message in channel general" }); + const navigationBefore = await page.evaluate(() => ({ + historyLength: history.length, + url: location.href, + })); + const sendsBefore = await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ); + + await targetLink.click(); + + const refusal = page.getByText( + "Finish or cancel your edit before leaving the thread.", + ); + await expect(refusal).toHaveCount(1); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(dirtyReply); + expect(await threadInput.textContent()).toBe(dirtyReply); + await expect(page).toHaveURL(navigationBefore.url); + expect(await page.evaluate(() => history.length)).toBe( + navigationBefore.historyLength, + ); + expect( + await page.evaluate( + () => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => entry.command === "send_channel_message", + ).length, + ), + ).toBe(sendsBefore); + + await threadInput.press("Escape"); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await targetLink.click(); + await expect + .poll(() => page.evaluate(() => history.length)) + .toBeGreaterThan(navigationBefore.historyLength); + await expect(threadPanel).toBeVisible(); + await expect( + threadPanel.locator( + `[data-message-id="${targetKind === "reply" ? sourceReplyId : sourceRootId}"]`, + ), + ).toBeVisible(); + }); +} + test("ArrowUp in an empty composer edits your last message right after sending", async ({ page, }) => {