fix(messages): preserve edits across thread layouts

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:53:42 -07:00
committed by Carl
parent 3562cbe520
commit c531e9ea31
6 changed files with 239 additions and 4 deletions

View File

@@ -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<TimelineMessage | null>(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}

View File

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

View File

@@ -461,6 +461,7 @@ function InboxMessageDetailPane({
author: editTarget.authorLabel,
body: editTarget.content,
id: editTarget.id,
isThreadReply: false,
imetaMedia: imetaMediaFromTags(editTarget.tags),
...editMentionState,
}

View File

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

View File

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

View File

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