fix(desktop): guard same-thread message navigation

Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-22 21:15:52 -07:00
committed by Carl
parent c3e667eb63
commit 1b207bac11
3 changed files with 103 additions and 23 deletions

View File

@@ -661,11 +661,7 @@ export function ChannelScreen({
setThreadReplyTargetId(null);
setEditTargetId(null);
}, [activeChannelId]);
useMessageLinkNavigationGuard(
requireThreadEditResolution,
activeChannelId,
effectiveOpenThreadHeadId,
);
useMessageLinkNavigationGuard(requireThreadEditResolution);
const mainTimelineTargetMessageId = useChannelRouteTarget({
activeChannel,
activeChannelId,

View File

@@ -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],
);
}

View File

@@ -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,
}) => {