diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 21927fa82f..19a837d56f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -467,7 +467,10 @@ export const ChannelPane = React.memo(function ChannelPane({ const handleRoutedEdit = React.useCallback( (message: TimelineMessage): boolean => { const currentEditTarget = editTargetRef.current; - if (currentEditTarget && currentEditTarget.id !== message.id) { + if ( + currentEditTarget?.isThreadReply === true && + currentEditTarget.id !== message.id + ) { pendingMainEditRef.current = null; toast.info("Finish or cancel your edit first."); return false; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 1ba9474f46..98a769ffc5 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -68,6 +68,7 @@ import { useIsHuddleTranscript, } from "@/features/channels/ui/useHuddleChannelMessages"; import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"; +import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; @@ -170,8 +171,16 @@ export function ChannelScreen({ const activeChannelId = activeChannel?.id ?? null; const isHuddleTranscript = useIsHuddleTranscript(activeChannelId); const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data; - const effectiveOpenThreadHeadId = - optimisticOpenThreadHeadId ?? openThreadHeadId; + const requireThreadEditResolutionRef = React.useRef<() => boolean>( + () => true, + ); + const effectiveOpenThreadHeadId = useHuddleThreadIsolation({ + closeThread: setOpenThreadHeadId, + isHuddleTranscript, + openThreadHeadId, + optimisticOpenThreadHeadId, + requireThreadEditResolutionRef, + }); const isNotifiedForEffectiveThread = effectiveOpenThreadHeadId != null ? isNotifiedForThread(effectiveOpenThreadHeadId) @@ -505,16 +514,7 @@ export function ChannelScreen({ threadReplyTargetId, toggleReactionMutation, }); - React.useEffect(() => { - if (!isHuddleTranscript || openThreadHeadId === null) return; - if (!requireThreadEditResolution()) return; - setOpenThreadHeadId(null); - }, [ - isHuddleTranscript, - openThreadHeadId, - requireThreadEditResolution, - setOpenThreadHeadId, - ]); + requireThreadEditResolutionRef.current = requireThreadEditResolution; const effectiveToggleReaction = React.useMemo( () => activeChannel && !activeChannel.archivedAt && activeChannel.isMember diff --git a/desktop/src/features/channels/ui/useChannelRouteTarget.ts b/desktop/src/features/channels/ui/useChannelRouteTarget.ts index 71250461ca..fd4ae3e2f6 100644 --- a/desktop/src/features/channels/ui/useChannelRouteTarget.ts +++ b/desktop/src/features/channels/ui/useChannelRouteTarget.ts @@ -118,7 +118,6 @@ export function useChannelRouteTarget({ if (!targetMessage.parentId) { if (!requireThreadEditResolution()) { - handledThreadRouteTargetRef.current = targetKey; return; } closeAgentSession(); @@ -148,7 +147,6 @@ export function useChannelRouteTarget({ return; } if (!requireThreadEditResolution()) { - handledThreadRouteTargetRef.current = targetKey; return; } diff --git a/desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs b/desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs new file mode 100644 index 0000000000..f75bc04b70 --- /dev/null +++ b/desktop/src/features/channels/ui/useHuddleThreadIsolation.test.mjs @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveHuddleOpenThreadHeadId } from "./useHuddleThreadIsolation.ts"; + +test("huddle transcripts synchronously hide URL thread state", () => { + assert.equal( + resolveHuddleOpenThreadHeadId({ + isHuddleTranscript: true, + openThreadHeadId: "url-thread", + optimisticOpenThreadHeadId: undefined, + }), + null, + ); +}); + +test("an optimistic null overrides the URL thread until navigation settles", () => { + assert.equal( + resolveHuddleOpenThreadHeadId({ + isHuddleTranscript: false, + openThreadHeadId: "url-thread", + optimisticOpenThreadHeadId: null, + }), + null, + ); + assert.equal( + resolveHuddleOpenThreadHeadId({ + isHuddleTranscript: false, + openThreadHeadId: "url-thread", + optimisticOpenThreadHeadId: undefined, + }), + "url-thread", + ); +}); diff --git a/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts b/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts new file mode 100644 index 0000000000..b794ce87be --- /dev/null +++ b/desktop/src/features/channels/ui/useHuddleThreadIsolation.ts @@ -0,0 +1,47 @@ +import * as React from "react"; + +type HuddleThreadIsolationOptions = { + closeThread: (threadId: string | null) => void; + isHuddleTranscript: boolean; + openThreadHeadId: string | null; + optimisticOpenThreadHeadId: string | null | undefined; + requireThreadEditResolutionRef: React.RefObject<() => boolean>; +}; + +export function resolveHuddleOpenThreadHeadId({ + isHuddleTranscript, + openThreadHeadId, + optimisticOpenThreadHeadId, +}: Pick< + HuddleThreadIsolationOptions, + "isHuddleTranscript" | "openThreadHeadId" | "optimisticOpenThreadHeadId" +>): string | null { + if (isHuddleTranscript) return null; + return optimisticOpenThreadHeadId === undefined + ? openThreadHeadId + : optimisticOpenThreadHeadId; +} + +export function useHuddleThreadIsolation({ + closeThread, + isHuddleTranscript, + openThreadHeadId, + optimisticOpenThreadHeadId, + requireThreadEditResolutionRef, +}: HuddleThreadIsolationOptions): string | null { + React.useEffect(() => { + if (!isHuddleTranscript || openThreadHeadId === null) return; + if (!requireThreadEditResolutionRef.current()) return; + closeThread(null); + }, [ + closeThread, + isHuddleTranscript, + openThreadHeadId, + requireThreadEditResolutionRef, + ]); + return resolveHuddleOpenThreadHeadId({ + isHuddleTranscript, + openThreadHeadId, + optimisticOpenThreadHeadId, + }); +} diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index f7b745cb5b..ebbab93bb9 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3476,6 +3476,123 @@ test("main ArrowUp refuses to replace a dirty thread edit", async ({ await expect(mainInput).toHaveText(""); }); +test("main composer switches directly between visible message edits", async ({ + page, +}) => { + const first = `Main edit switch first ${Date.now()}`; + const second = `Main edit switch second ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(first); + await mainInput.press("Enter"); + await expect( + page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: first }), + ).toBeVisible(); + await page.waitForTimeout(1_100); + await mainInput.fill(second); + await mainInput.press("Enter"); + await expect( + page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: second }), + ).toBeVisible(); + + await mainInput.click(); + await page.keyboard.press("ArrowUp"); + await expect(mainInput).toHaveText(second); + + const firstMessage = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: first }) + .last(); + await firstMessage.hover(); + await firstMessage.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(mainInput).toHaveText(first); + await expect(mainInput).toBeFocused(); + await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount( + 0, + ); +}); + +test("a refused message deep link retries after the thread edit is canceled", async ({ + page, +}) => { + const sourceRoot = `Deep link retry source ${Date.now()}`; + const reply = `Deep link retry reply ${Date.now()}`; + const destinationRoot = `Deep link retry destination ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(destinationRoot); + await mainInput.press("Enter"); + const destination = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: destinationRoot }) + .last(); + const destinationId = await destination.getAttribute("data-message-id"); + expect(destinationId).not.toBeNull(); + await mainInput.fill( + `Retry link buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${destinationId}`, + ); + await mainInput.press("Enter"); + const destinationLink = page + .getByTestId("message-row") + .filter({ hasText: "Retry link" }) + .last() + .getByRole("button", { name: "Open message in channel general" }); + await expect(destinationLink).toBeVisible(); + + await mainInput.fill(sourceRoot); + await mainInput.press("Enter"); + const source = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .filter({ hasText: sourceRoot }) + .last(); + 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"); + await threadInput.fill(reply); + await threadInput.press("Enter"); + const threadReply = threadPanel + .getByTestId("message-row") + .filter({ hasText: reply }) + .last(); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(`${reply} unsaved`); + + await destinationLink.click(); + await expect( + page.getByText("Finish or cancel your edit before leaving the thread."), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await destinationLink.click(); + const routedDestination = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${destinationId}"]`); + await expect(threadPanel).toBeHidden(); + await expect(routedDestination).toBeVisible(); + await expect(routedDestination).toHaveClass(/route-target-highlight-fade/); +}); + test("ArrowUp in an empty composer edits your last message right after sending", async ({ page, }) => {