diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 931818e4e3..8b59a1302e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -23,6 +23,7 @@ import { hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext"; +import { isThreadReply } from "@/features/messages/lib/threading"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; @@ -220,7 +221,7 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel, currentPubkey ?? null, ); - const isEditInThread = editTarget?.isThreadReply ?? false; + const isEditInThread = editTarget?.isThreadReply === true; const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; const findLastOwnEditable = React.useCallback( @@ -457,6 +458,27 @@ export const ChannelPane = React.memo(function ChannelPane({ useFocusThreadDrawer, onCloseThread, ); + const pendingMainEditRef = React.useRef(null); + const handleRoutedEdit = React.useCallback( + (message: TimelineMessage) => { + if ( + !isThreadReply(message.tags ?? []) && + (isSinglePanelView || useFocusThreadDrawer) + ) { + pendingMainEditRef.current = message; + onCloseThread(); + return; + } + onEdit?.(message); + }, + [isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer], + ); + React.useEffect(() => { + const pendingMainEdit = pendingMainEditRef.current; + if (!pendingMainEdit || isSinglePanelView || channelIsCovered) return; + pendingMainEditRef.current = null; + onEdit?.(pendingMainEdit); + }, [channelIsCovered, isSinglePanelView, onEdit]); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ activeThreadHeadId: threadHeadMessage?.id ?? null, @@ -612,7 +634,7 @@ export const ChannelPane = React.memo(function ChannelPane({ firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} onDelete={onDelete} - onEdit={onEdit} + onEdit={handleRoutedEdit} onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} onReply={timelineReplyHandler} @@ -804,7 +826,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelReply={onCancelThreadReply} onClose={onCloseThread} onDelete={onDelete} - onEdit={onEdit} + onEdit={handleRoutedEdit} onEditLastOwnMessage={handleEditLastOwnThreadMessage} onEditSave={onEditSave} onFollowThread={onFollowThread} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index f9c57f6656..154abebe49 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import { toast } from "sonner"; import type { useDeleteMessageMutation, @@ -118,6 +119,10 @@ export function useChannelPaneHandlers({ }, [setThreadReplyTargetId]); const handleCloseThread = React.useCallback(() => { + if (editTargetIdRef.current) { + toast.info("Finish or cancel your edit before closing the thread."); + return; + } deferPanelState(() => { onOptimisticOpenThreadHeadIdChange(null); setOpenThreadHeadId(null); diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9192c64952..373ef80f45 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -461,6 +461,7 @@ function InboxMessageDetailPane({ author: editTarget.authorLabel, body: editTarget.content, id: editTarget.id, + isThreadReply: false, imetaMedia: imetaMediaFromTags(editTarget.tags), ...editMentionState, } diff --git a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs index e0a7dedc3e..ac1ed2d8c3 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs +++ b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs @@ -85,8 +85,19 @@ test("edit target records semantic thread ownership", () => { () => false, ); + const broadcastReply = buildMessageComposerEditTarget( + message("Broadcast reply", [ + ["h", "channel-id"], + ["e", "root-id", "", "reply"], + ["broadcast", "1"], + ]), + undefined, + () => false, + ); + assert.equal(root.isThreadReply, false); assert.equal(reply.isThreadReply, true); + assert.equal(broadcastReply.isThreadReply, false); }); test("edit target separates resolved refs from identities missing profiles", () => { diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index f95c1f6e2e..e1bc409802 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -10,7 +10,7 @@ export type MessageComposerEditTarget = { author: string; body: string; id: string; - isThreadReply?: boolean; + isThreadReply: boolean; /** * NIP-92 imeta attachments on the original event, in tag order. Loaded * into the composer's pending-imeta state on edit-open so the user sees diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 4bd88b00c0..2252437e60 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -3054,6 +3054,202 @@ test("editing a pre-seeded thread reply uses and focuses the thread composer", a await expect(threadInput).toBeFocused(); }); +test("editing a broadcast reply from a thread returns to the main composer", async ({ + page, +}) => { + await page.goto("/"); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function", + ); + const { broadcastId, rootId } = await page.evaluate(() => { + const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + if (!emit) throw new Error("Mock message emitter is unavailable."); + const rootEvent = emit({ + channelName: "general", + content: "Broadcast edit root", + }); + const broadcastEvent = emit({ + channelName: "general", + content: "Broadcast reply to edit", + parentEventId: rootEvent.id, + extraTags: [["broadcast", "1"]], + }); + return { broadcastId: broadcastEvent.id, rootId: rootEvent.id }; + }); + + await page.getByTestId("channel-general").click(); + const timelineRoot = page.locator(`[data-message-id="${rootId}"]`); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + const broadcastReply = threadPanel.locator( + `[data-message-id="${broadcastId}"]`, + ); + await expect(broadcastReply).toContainText("Broadcast reply to edit"); + await broadcastReply.hover(); + await broadcastReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await expect(mainInput).toHaveText("Broadcast reply to edit"); + await expect(mainInput).toBeFocused(); +}); + +test("editing a live thread reply uses and focuses the thread composer", async ({ + page, +}) => { + const root = `Live reply edit root ${Date.now()}`; + const reply = `Live reply edit ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .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").last(); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(reply); + await expect(threadInput).toBeFocused(); +}); + +test("editing a thread root in single-panel view returns to the main composer", async ({ + page, +}) => { + await page.setViewportSize({ width: 860, height: 720 }); + const root = `Narrow root edit ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const input = page.getByTestId("message-input"); + await input.fill(root); + await input.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadRoot = threadPanel.getByTestId("message-row").first(); + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(threadPanel).toBeHidden(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("editing a thread root in focus mode dismisses the drawer before focusing the main composer", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem("buzz.channels.threadViewMode", "focus"); + }); + const root = `Focus root edit ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .getByRole("button", { name: "Reply" }) + .click({ force: true }); + + const drawer = page.getByTestId("focus-thread-drawer"); + const threadRoot = drawer.getByTestId("message-row").first(); + await threadRoot.hover(); + await threadRoot.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + + await expect(drawer).toBeHidden(); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("closing a thread while editing a reply preserves the typed edit", async ({ + page, +}) => { + const root = `Close guard root ${Date.now()}`; + const reply = `Close guard reply ${Date.now()}`; + const edited = `${reply} with unsaved text`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + const timelineRoot = page + .getByTestId("message-timeline") + .getByTestId("message-row") + .last(); + await timelineRoot.hover(); + await timelineRoot + .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").last(); + await expect(threadReply).toContainText(reply); + await threadReply.hover(); + await threadReply.getByRole("button", { name: "More actions" }).click(); + await page.getByRole("menuitem", { name: "Edit message" }).click(); + await threadInput.fill(edited); + + await threadPanel.getByTestId("auxiliary-panel-close").click(); + + await expect(threadPanel).toBeVisible(); + await expect(threadPanel.getByTestId("edit-target")).toBeVisible(); + await expect(threadInput).toHaveText(edited); + await expect( + page.getByText("Finish or cancel your edit before closing the thread."), + ).toBeVisible(); +}); + test("ArrowUp in an empty composer edits your last message right after sending", async ({ page, }) => {