From efef6b7090b18bb069399b577f6d3645fa9dc6aa Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sat, 22 Aug 2026 11:03:00 -0700 Subject: [PATCH] fix(messages): route edits to the owning composer Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../src/features/channels/ui/ChannelPane.tsx | 6 +- .../features/channels/ui/ChannelPane.types.ts | 11 +-- .../messages/lib/draftMentionRefs.test.mjs | 20 +++++ .../features/messages/lib/draftMentionRefs.ts | 2 + .../features/messages/ui/MessageActionBar.tsx | 2 +- .../messages/ui/MessageComposer.types.ts | 1 + desktop/tests/e2e/messaging.spec.ts | 76 +++++++++++++++++++ 7 files changed, 103 insertions(+), 15 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed4..931818e4e3 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -220,11 +220,7 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel, currentPubkey ?? null, ); - const isEditInThread = - editTarget != null && - threadHeadMessage != null && - (editTarget.id === threadHeadMessage.id || - threadMessages.some((entry) => entry.message.id === editTarget.id)); + const isEditInThread = editTarget?.isThreadReply ?? false; const mainEditTarget = editTarget && !isEditInThread ? editTarget : null; const threadEditTarget = editTarget && isEditInThread ? editTarget : null; const findLastOwnEditable = React.useCallback( diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073..7ac3930b84 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -1,13 +1,12 @@ import type * as React from "react"; import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar"; import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; -import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { ProfilePanelTab, ProfilePanelView, @@ -37,13 +36,7 @@ export type ChannelPaneProps = { botTypingEntries: TypingIndicatorEntry[]; channelManagementOpen?: boolean; currentPubkey?: string; - editTarget?: { - author: string; - body: string; - id: string; - imetaMedia?: ImetaMedia[]; - mentionRefs?: DraftMentionRef[]; - } | null; + editTarget?: MessageComposerEditTarget | null; fetchOlder?: () => Promise; header?: React.ReactNode; hasOlderMessages?: boolean; diff --git a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs index 87ec604464..e0a7dedc3e 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.test.mjs +++ b/desktop/src/features/messages/lib/draftMentionRefs.test.mjs @@ -69,6 +69,26 @@ test("edit target preserves tagged identities while profiles are unavailable", ( assert.deepEqual(target.unresolvedMentionPubkeys, [ALICE, BOB]); }); +test("edit target records semantic thread ownership", () => { + const root = buildMessageComposerEditTarget( + message("Root", [["h", "channel-id"]]), + undefined, + () => false, + ); + const reply = buildMessageComposerEditTarget( + message("Reply", [ + ["h", "channel-id"], + ["e", "root-id", "", "root"], + ["e", "root-id", "", "reply"], + ]), + undefined, + () => false, + ); + + assert.equal(root.isThreadReply, false); + assert.equal(reply.isThreadReply, true); +}); + test("edit target separates resolved refs from identities missing profiles", () => { const target = buildMessageComposerEditTarget( message("Please review this, @Alice and @Bob.", [ diff --git a/desktop/src/features/messages/lib/draftMentionRefs.ts b/desktop/src/features/messages/lib/draftMentionRefs.ts index 65c7a68fec..7aebf86b2f 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.ts +++ b/desktop/src/features/messages/lib/draftMentionRefs.ts @@ -1,5 +1,6 @@ import { hasMention } from "@/features/messages/lib/hasMention"; import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; +import { isThreadReply } from "@/features/messages/lib/threading"; import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { TimelineMessage } from "@/features/messages/types"; import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types"; @@ -95,6 +96,7 @@ export function buildMessageComposerEditTarget( author: message.author, body: message.body, id: message.id, + isThreadReply: isThreadReply(message.tags ?? []), imetaMedia: imetaMediaFromTags(message.tags), ...mentionState, }; diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 11163aa8c7..4fcf0f067a 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -143,7 +143,7 @@ function MoreActionsMenu({ {onEdit ? ( { + onSelect={() => { editJustSelectedRef.current = true; onEdit(message); }} diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index 517b9afb00..f95c1f6e2e 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -10,6 +10,7 @@ export type MessageComposerEditTarget = { author: string; body: string; id: string; + 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 9a93086cf8..3abbddab40 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -2970,6 +2970,82 @@ test("thread composer keeps focus after sending a thread reply", async ({ await expect(threadInput).toBeFocused(); }); +test("editing the thread root uses and focuses the main composer", async ({ + page, +}) => { + const root = `Root edit routing ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const mainInput = page + .getByTestId("channel-composer-overlay") + .getByTestId("message-input"); + await mainInput.fill(root); + await mainInput.press("Enter"); + + const timeline = page.getByTestId("message-timeline"); + const timelineRoot = timeline.getByTestId("message-row").last(); + await expect(timelineRoot).toContainText(root); + await timelineRoot.hover(); + await timelineRoot.getByRole("button", { name: "Reply" }).click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + 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(page.getByTestId("edit-target")).toHaveCount(1); + await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0); + await expect(mainInput).toHaveText(root); + await expect(mainInput).toBeFocused(); +}); + +test("editing a thread reply uses and focuses the thread composer", async ({ + page, +}) => { + const root = `Reply edit routing root ${Date.now()}`; + const reply = `Reply edit routing ${Date.now()}`; + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + 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.scrollIntoViewIfNeeded(); + 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"); + await expect(threadPanel).toContainText(reply); + + const threadReply = threadPanel.getByTestId("message-row").last(); + 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("ArrowUp in an empty composer edits your last message right after sending", async ({ page, }) => {