fix(messages): route edits to the owning composer

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 11:03:00 -07:00
committed by Carl
parent 0e69b3fd7c
commit efef6b7090
7 changed files with 103 additions and 15 deletions

View File

@@ -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(

View File

@@ -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<void>;
header?: React.ReactNode;
hasOlderMessages?: boolean;

View File

@@ -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.", [

View File

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

View File

@@ -143,7 +143,7 @@ function MoreActionsMenu({
{onEdit ? (
<DropdownMenuItem
data-testid={`edit-message-${message.id}`}
onClick={() => {
onSelect={() => {
editJustSelectedRef.current = true;
onEdit(message);
}}

View File

@@ -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

View File

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