mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(messages): preserve guarded edit transitions
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
@@ -467,7 +467,10 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
const handleRoutedEdit = React.useCallback(
|
||||
(message: TimelineMessage): boolean => {
|
||||
const currentEditTarget = editTargetRef.current;
|
||||
if (currentEditTarget && currentEditTarget.id !== message.id) {
|
||||
if (
|
||||
currentEditTarget?.isThreadReply === true &&
|
||||
currentEditTarget.id !== message.id
|
||||
) {
|
||||
pendingMainEditRef.current = null;
|
||||
toast.info("Finish or cancel your edit first.");
|
||||
return false;
|
||||
|
||||
@@ -68,6 +68,7 @@ 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";
|
||||
@@ -170,8 +171,16 @@ export function ChannelScreen({
|
||||
const activeChannelId = activeChannel?.id ?? null;
|
||||
const isHuddleTranscript = useIsHuddleTranscript(activeChannelId);
|
||||
const relaySelfPubkey = useRelaySelfQuery(activeChannel !== null).data;
|
||||
const effectiveOpenThreadHeadId =
|
||||
optimisticOpenThreadHeadId ?? openThreadHeadId;
|
||||
const requireThreadEditResolutionRef = React.useRef<() => boolean>(
|
||||
() => true,
|
||||
);
|
||||
const effectiveOpenThreadHeadId = useHuddleThreadIsolation({
|
||||
closeThread: setOpenThreadHeadId,
|
||||
isHuddleTranscript,
|
||||
openThreadHeadId,
|
||||
optimisticOpenThreadHeadId,
|
||||
requireThreadEditResolutionRef,
|
||||
});
|
||||
const isNotifiedForEffectiveThread =
|
||||
effectiveOpenThreadHeadId != null
|
||||
? isNotifiedForThread(effectiveOpenThreadHeadId)
|
||||
@@ -505,16 +514,7 @@ export function ChannelScreen({
|
||||
threadReplyTargetId,
|
||||
toggleReactionMutation,
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (!isHuddleTranscript || openThreadHeadId === null) return;
|
||||
if (!requireThreadEditResolution()) return;
|
||||
setOpenThreadHeadId(null);
|
||||
}, [
|
||||
isHuddleTranscript,
|
||||
openThreadHeadId,
|
||||
requireThreadEditResolution,
|
||||
setOpenThreadHeadId,
|
||||
]);
|
||||
requireThreadEditResolutionRef.current = requireThreadEditResolution;
|
||||
const effectiveToggleReaction = React.useMemo(
|
||||
() =>
|
||||
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
|
||||
|
||||
@@ -118,7 +118,6 @@ export function useChannelRouteTarget({
|
||||
|
||||
if (!targetMessage.parentId) {
|
||||
if (!requireThreadEditResolution()) {
|
||||
handledThreadRouteTargetRef.current = targetKey;
|
||||
return;
|
||||
}
|
||||
closeAgentSession();
|
||||
@@ -148,7 +147,6 @@ export function useChannelRouteTarget({
|
||||
return;
|
||||
}
|
||||
if (!requireThreadEditResolution()) {
|
||||
handledThreadRouteTargetRef.current = targetKey;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { resolveHuddleOpenThreadHeadId } from "./useHuddleThreadIsolation.ts";
|
||||
|
||||
test("huddle transcripts synchronously hide URL thread state", () => {
|
||||
assert.equal(
|
||||
resolveHuddleOpenThreadHeadId({
|
||||
isHuddleTranscript: true,
|
||||
openThreadHeadId: "url-thread",
|
||||
optimisticOpenThreadHeadId: undefined,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("an optimistic null overrides the URL thread until navigation settles", () => {
|
||||
assert.equal(
|
||||
resolveHuddleOpenThreadHeadId({
|
||||
isHuddleTranscript: false,
|
||||
openThreadHeadId: "url-thread",
|
||||
optimisticOpenThreadHeadId: null,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveHuddleOpenThreadHeadId({
|
||||
isHuddleTranscript: false,
|
||||
openThreadHeadId: "url-thread",
|
||||
optimisticOpenThreadHeadId: undefined,
|
||||
}),
|
||||
"url-thread",
|
||||
);
|
||||
});
|
||||
47
desktop/src/features/channels/ui/useHuddleThreadIsolation.ts
Normal file
47
desktop/src/features/channels/ui/useHuddleThreadIsolation.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import * as React from "react";
|
||||
|
||||
type HuddleThreadIsolationOptions = {
|
||||
closeThread: (threadId: string | null) => void;
|
||||
isHuddleTranscript: boolean;
|
||||
openThreadHeadId: string | null;
|
||||
optimisticOpenThreadHeadId: string | null | undefined;
|
||||
requireThreadEditResolutionRef: React.RefObject<() => boolean>;
|
||||
};
|
||||
|
||||
export function resolveHuddleOpenThreadHeadId({
|
||||
isHuddleTranscript,
|
||||
openThreadHeadId,
|
||||
optimisticOpenThreadHeadId,
|
||||
}: Pick<
|
||||
HuddleThreadIsolationOptions,
|
||||
"isHuddleTranscript" | "openThreadHeadId" | "optimisticOpenThreadHeadId"
|
||||
>): string | null {
|
||||
if (isHuddleTranscript) return null;
|
||||
return optimisticOpenThreadHeadId === undefined
|
||||
? openThreadHeadId
|
||||
: optimisticOpenThreadHeadId;
|
||||
}
|
||||
|
||||
export function useHuddleThreadIsolation({
|
||||
closeThread,
|
||||
isHuddleTranscript,
|
||||
openThreadHeadId,
|
||||
optimisticOpenThreadHeadId,
|
||||
requireThreadEditResolutionRef,
|
||||
}: HuddleThreadIsolationOptions): string | null {
|
||||
React.useEffect(() => {
|
||||
if (!isHuddleTranscript || openThreadHeadId === null) return;
|
||||
if (!requireThreadEditResolutionRef.current()) return;
|
||||
closeThread(null);
|
||||
}, [
|
||||
closeThread,
|
||||
isHuddleTranscript,
|
||||
openThreadHeadId,
|
||||
requireThreadEditResolutionRef,
|
||||
]);
|
||||
return resolveHuddleOpenThreadHeadId({
|
||||
isHuddleTranscript,
|
||||
openThreadHeadId,
|
||||
optimisticOpenThreadHeadId,
|
||||
});
|
||||
}
|
||||
@@ -3476,6 +3476,123 @@ test("main ArrowUp refuses to replace a dirty thread edit", async ({
|
||||
await expect(mainInput).toHaveText("");
|
||||
});
|
||||
|
||||
test("main composer switches directly between visible message edits", async ({
|
||||
page,
|
||||
}) => {
|
||||
const first = `Main edit switch first ${Date.now()}`;
|
||||
const second = `Main edit switch second ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const mainInput = page
|
||||
.getByTestId("channel-composer-overlay")
|
||||
.getByTestId("message-input");
|
||||
await mainInput.fill(first);
|
||||
await mainInput.press("Enter");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: first }),
|
||||
).toBeVisible();
|
||||
await page.waitForTimeout(1_100);
|
||||
await mainInput.fill(second);
|
||||
await mainInput.press("Enter");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: second }),
|
||||
).toBeVisible();
|
||||
|
||||
await mainInput.click();
|
||||
await page.keyboard.press("ArrowUp");
|
||||
await expect(mainInput).toHaveText(second);
|
||||
|
||||
const firstMessage = page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: first })
|
||||
.last();
|
||||
await firstMessage.hover();
|
||||
await firstMessage.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
||||
|
||||
await expect(mainInput).toHaveText(first);
|
||||
await expect(mainInput).toBeFocused();
|
||||
await expect(page.getByText("Finish or cancel your edit first.")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("a refused message deep link retries after the thread edit is canceled", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sourceRoot = `Deep link retry source ${Date.now()}`;
|
||||
const reply = `Deep link retry reply ${Date.now()}`;
|
||||
const destinationRoot = `Deep link retry destination ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const mainInput = page
|
||||
.getByTestId("channel-composer-overlay")
|
||||
.getByTestId("message-input");
|
||||
await mainInput.fill(destinationRoot);
|
||||
await mainInput.press("Enter");
|
||||
const destination = page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: destinationRoot })
|
||||
.last();
|
||||
const destinationId = await destination.getAttribute("data-message-id");
|
||||
expect(destinationId).not.toBeNull();
|
||||
await mainInput.fill(
|
||||
`Retry link buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=${destinationId}`,
|
||||
);
|
||||
await mainInput.press("Enter");
|
||||
const destinationLink = page
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: "Retry link" })
|
||||
.last()
|
||||
.getByRole("button", { name: "Open message in channel general" });
|
||||
await expect(destinationLink).toBeVisible();
|
||||
|
||||
await mainInput.fill(sourceRoot);
|
||||
await mainInput.press("Enter");
|
||||
const source = page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.filter({ hasText: sourceRoot })
|
||||
.last();
|
||||
await source.hover();
|
||||
await source.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")
|
||||
.filter({ hasText: reply })
|
||||
.last();
|
||||
await threadReply.hover();
|
||||
await threadReply.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
||||
await threadInput.fill(`${reply} unsaved`);
|
||||
|
||||
await destinationLink.click();
|
||||
await expect(
|
||||
page.getByText("Finish or cancel your edit before leaving the thread."),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await destinationLink.click();
|
||||
const routedDestination = page
|
||||
.getByTestId("message-timeline")
|
||||
.locator(`[data-message-id="${destinationId}"]`);
|
||||
await expect(threadPanel).toBeHidden();
|
||||
await expect(routedDestination).toBeVisible();
|
||||
await expect(routedDestination).toHaveClass(/route-target-highlight-fade/);
|
||||
});
|
||||
|
||||
test("ArrowUp in an empty composer edits your last message right after sending", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user