fix(messages): prevent hidden keyboard edit targets

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 13:23:43 -07:00
committed by Carl
parent 212a3118eb
commit 6467a36724
6 changed files with 129 additions and 40 deletions

View File

@@ -245,12 +245,6 @@ export const ChannelPane = React.memo(function ChannelPane({
},
[onEdit, currentPubkey],
);
const handleEditLastOwnMainMessage = React.useCallback((): boolean => {
const target = findLastOwnEditable(messages);
if (!target || !onEdit) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, messages, onEdit]);
const timeoutState = useTimeoutState();
// A moderation DM (1:1 with the relay identity) is read-only for the member;
@@ -496,6 +490,12 @@ export const ChannelPane = React.memo(function ChannelPane({
},
[isSinglePanelView, onCloseThread, onEdit, useFocusThreadDrawer],
);
const handleEditLastOwnMainMessage = React.useCallback((): boolean => {
const target = findLastOwnEditable(
mainTimelineEntries.map((entry) => entry.message),
);
return target ? handleRoutedEdit(target) : false;
}, [findLastOwnEditable, handleRoutedEdit, mainTimelineEntries]);
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
const scope: TimelineMessage[] = [];
if (threadHeadMessage) scope.push(threadHeadMessage);
@@ -561,6 +561,7 @@ export const ChannelPane = React.memo(function ChannelPane({
useFocusThreadDrawer ? (
<FocusThreadDrawer
channelName={activeChannel?.name ?? "channel"}
hasActiveEdit={threadEditTarget !== null}
key={THREAD_SURFACE_KEY}
onClose={onCloseThread}
>

View File

@@ -68,7 +68,6 @@ 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";
@@ -171,12 +170,8 @@ export function ChannelScreen({
const activeChannelId = activeChannel?.id ?? null;
const isHuddleTranscript = useIsHuddleTranscript(activeChannelId);
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
const effectiveOpenThreadHeadId = useHuddleThreadIsolation({
closeThread: setOpenThreadHeadId,
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
});
const effectiveOpenThreadHeadId =
optimisticOpenThreadHeadId ?? openThreadHeadId;
const isNotifiedForEffectiveThread =
effectiveOpenThreadHeadId != null
? isNotifiedForThread(effectiveOpenThreadHeadId)
@@ -510,6 +505,16 @@ export function ChannelScreen({
threadReplyTargetId,
toggleReactionMutation,
});
React.useEffect(() => {
if (!isHuddleTranscript || openThreadHeadId === null) return;
if (!requireThreadEditResolution()) return;
setOpenThreadHeadId(null);
}, [
isHuddleTranscript,
openThreadHeadId,
requireThreadEditResolution,
setOpenThreadHeadId,
]);
const effectiveToggleReaction = React.useMemo(
() =>
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
@@ -662,6 +667,7 @@ export function ChannelScreen({
activeChannel,
activeChannelId,
closeAgentSession: handleCloseAgentSession,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,

View File

@@ -11,6 +11,7 @@ import { cn } from "@/shared/lib/cn";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
hasActiveEdit: boolean;
onClose: () => void;
};
@@ -139,6 +140,7 @@ const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
export function FocusThreadDrawer({
channelName,
children,
hasActiveEdit,
onClose,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
@@ -151,9 +153,9 @@ export function FocusThreadDrawer({
if (event.key !== "Escape") return;
const target = event.target;
if (
hasActiveEdit &&
target instanceof Node &&
drawerRef.current?.contains(target) &&
drawerRef.current.querySelector('[data-testid="edit-target"]')
drawerRef.current?.contains(target)
) {
return;
}
@@ -166,7 +168,7 @@ export function FocusThreadDrawer({
return () => {
window.removeEventListener("keydown", handleEscape, { capture: true });
};
}, [onClose]);
}, [hasActiveEdit, onClose]);
React.useLayoutEffect(() => {
previousFocusRef.current =

View File

@@ -56,6 +56,7 @@ export function useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
@@ -68,6 +69,7 @@ export function useChannelRouteTarget({
activeChannel: Channel | null;
activeChannelId: string | null;
closeAgentSession: () => void;
requireThreadEditResolution: () => boolean;
setEditTargetId: React.Dispatch<React.SetStateAction<string | null>>;
setExpandedThreadReplyIds: React.Dispatch<React.SetStateAction<Set<string>>>;
setOpenThreadHeadId: PanelValueSetter;
@@ -115,6 +117,10 @@ export function useChannelRouteTarget({
}
if (!targetMessage.parentId) {
if (!requireThreadEditResolution()) {
handledThreadRouteTargetRef.current = targetKey;
return;
}
closeAgentSession();
// Root message links should open the reply panel for that root. The
// timeline scroll/highlight target alone is not enough: root links have
@@ -141,6 +147,10 @@ export function useChannelRouteTarget({
if (!routeTarget) {
return;
}
if (!requireThreadEditResolution()) {
handledThreadRouteTargetRef.current = targetKey;
return;
}
closeAgentSession();
// Replace so the deep-link entry itself carries the opened thread —
@@ -156,6 +166,7 @@ export function useChannelRouteTarget({
activeChannel,
activeChannelId,
closeAgentSession,
requireThreadEditResolution,
setEditTargetId,
setExpandedThreadReplyIds,
setOpenThreadHeadId,

View File

@@ -1,24 +0,0 @@
import * as React from "react";
type HuddleThreadIsolationOptions = {
closeThread: (threadId: string | null) => void;
isHuddleTranscript: boolean;
openThreadHeadId: string | null;
optimisticOpenThreadHeadId: string | null | undefined;
};
export function useHuddleThreadIsolation({
closeThread,
isHuddleTranscript,
openThreadHeadId,
optimisticOpenThreadHeadId,
}: HuddleThreadIsolationOptions): string | null {
React.useEffect(() => {
if (!isHuddleTranscript || openThreadHeadId === null) return;
closeThread(null);
}, [closeThread, isHuddleTranscript, openThreadHeadId]);
if (isHuddleTranscript) return null;
return optimisticOpenThreadHeadId === undefined
? openThreadHeadId
: optimisticOpenThreadHeadId;
}

View File

@@ -3383,6 +3383,99 @@ test("closing a thread while editing a reply preserves the typed edit", async ({
).toBeVisible();
});
test("main ArrowUp ignores closed-thread replies and edits the visible timeline message", async ({
page,
}) => {
const root = `Main ArrowUp root ${Date.now()}`;
const reply = `Main ArrowUp hidden reply ${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");
await expect(threadPanel).toContainText(reply);
await threadPanel.getByTestId("auxiliary-panel-close").click();
await expect(threadPanel).toBeHidden();
await mainInput.click();
await page.keyboard.press("ArrowUp");
await expect(page.getByTestId("edit-target")).toBeVisible();
await expect(mainInput).toHaveText(root);
// No hidden reply edit may block reopening its thread.
await mainInput.press("Escape");
await timelineRoot.hover();
await timelineRoot
.getByRole("button", { name: "Reply" })
.click({ force: true });
await expect(threadPanel).toBeVisible();
});
test("main ArrowUp refuses to replace a dirty thread edit", async ({
page,
}) => {
const root = `Main ArrowUp refusal root ${Date.now()}`;
const reply = `Main ArrowUp refusal reply ${Date.now()}`;
const unsaved = `${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(unsaved);
await mainInput.click();
await page.keyboard.press("ArrowUp");
await expect(
page.getByText("Finish or cancel your edit first."),
).toBeVisible();
await expect(threadPanel.getByTestId("edit-target")).toBeVisible();
await expect(threadInput).toHaveText(unsaved);
await expect(mainInput).toHaveText("");
// Refusal must not arm a deferred edit that appears after cancellation.
await threadInput.press("Escape");
await expect(threadPanel.getByTestId("edit-target")).toHaveCount(0);
await expect(mainInput).toHaveText("");
});
test("ArrowUp in an empty composer edits your last message right after sending", async ({
page,
}) => {