mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
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:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user