From 97a0ee9b07c5df9a095ccb854d9a467e0d4cde45 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 20 Oct 2026 12:21:36 -0400 Subject: [PATCH] fix(desktop): dismiss stale channel suggestions before edit submission Signed-off-by: Tee8z --- .../src/features/forum/ui/ForumComposer.tsx | 5 ++ .../lib/useChannelLinks.jsdom-test.mjs | 82 +++++++++++++++++++ .../features/messages/lib/useChannelLinks.ts | 16 ++++ .../messages/lib/useRichTextEditor.ts | 6 ++ .../features/messages/ui/MessageComposer.tsx | 5 ++ desktop/tests/e2e/empty-edit-delete.spec.ts | 24 ++++++ 6 files changed, 138 insertions(+) create mode 100644 desktop/src/features/messages/lib/useChannelLinks.jsdom-test.mjs diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 724487501eb..1b4b4c73137 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -132,6 +132,10 @@ function ForumComposerVisit({ submitModeRef.current = onSecondarySubmit ? submitMode : "primary"; const isAutocompleteOpenRef = React.useRef(false); + const isChannelAutocompleteOpenRef = React.useRef(false); + React.useLayoutEffect(() => { + isChannelAutocompleteOpenRef.current = channelLinks.isChannelOpen; + }, [channelLinks.isChannelOpen]); isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen; @@ -158,6 +162,7 @@ function ForumComposerVisit({ getMentionIdentities: mentions.getMentionIdentities, onSubmit: () => submitMessageRef.current(), isAutocompleteOpen: isAutocompleteOpenRef, + isChannelAutocompleteOpen: isChannelAutocompleteOpenRef, onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, diff --git a/desktop/src/features/messages/lib/useChannelLinks.jsdom-test.mjs b/desktop/src/features/messages/lib/useChannelLinks.jsdom-test.mjs new file mode 100644 index 00000000000..da9f83f25cd --- /dev/null +++ b/desktop/src/features/messages/lib/useChannelLinks.jsdom-test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; + +import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; +import { useChannelLinks } from "./useChannelLinks.ts"; + +const channels = [ + { + id: "general", + name: "general", + channelType: "stream", + archivedAt: null, + }, +]; + +function mountChannelLinks(t) { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const root = createRoot(document.createElement("div")); + let current; + function Harness() { + current = useChannelLinks(); + return null; + } + act(() => { + root.render( + React.createElement( + ChannelNavigationProvider, + { channels }, + React.createElement(Harness), + ), + ); + }); + t.after(() => act(() => root.unmount())); + return () => current; +} + +for (const replacement of ["", "Edited message", "Welcome to #general "]) { + test(`channel suggestions close immediately after replacing the query with ${JSON.stringify(replacement)}`, (t) => { + const links = mountChannelLinks(t); + act(() => links().updateChannelQuery("Welcome to #general", 19)); + act(() => t.mock.timers.tick(120)); + assert.equal(links().isChannelOpen, true); + + act(() => links().updateChannelQuery(replacement, replacement.length)); + + // Enter must submit the current edit before the debounce window ends. + assert.equal(links().isChannelOpen, false); + for (const key of ["Enter", "Tab"]) { + assert.deepEqual( + links().handleChannelKeyDown({ + key, + preventDefault: () => + assert.fail("stale suggestion consumed the key"), + }), + { handled: false }, + ); + } + act(() => t.mock.timers.tick(120)); + assert.equal(links().isChannelOpen, false); + }); +} + +test("clearing a pending query cancels it; a later valid query still opens", (t) => { + const links = mountChannelLinks(t); + act(() => links().updateChannelQuery("#gen", 4)); + assert.equal(links().isChannelOpen, false); + act(() => links().updateChannelQuery("", 0)); + act(() => t.mock.timers.tick(120)); + assert.equal(links().isChannelOpen, false); + + act(() => links().updateChannelQuery("#gen", 4)); + act(() => t.mock.timers.tick(120)); + assert.equal(links().isChannelOpen, true); + const event = { key: "Enter", preventDefault: t.mock.fn() }; + assert.deepEqual(links().handleChannelKeyDown(event), { + handled: true, + suggestion: { id: "general", name: "general", channelType: "stream" }, + }); + assert.equal(event.preventDefault.mock.callCount(), 1); +}); diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 4b703f53cd6..fb4555e4e1a 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -122,6 +122,22 @@ export function useChannelLinks() { if (debounceTimerRef.current !== null) { clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + // Closing must not wait for the debounce: Enter on a cleared or + // replaced edit must submit it, not insert the previous suggestion. + if ( + !detectPrefixQuery( + "#", + value, + cursorPosition, + knownNamesLowerRef.current, + ) + ) { + setChannelQuery(null); + setChannelSelectedIndex(0); + return; } debounceTimerRef.current = setTimeout(() => { diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 837822f01f8..b4cb5712b62 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -109,6 +109,8 @@ export type RichTextEditorOptions = { onEditLastOwnMessage?: () => boolean; /** When true, plain Enter is passed through (e.g. to select an autocomplete item). */ isAutocompleteOpen?: React.RefObject; + /** Prevent paragraph splitting from dismissing a pending channel selection. */ + isChannelAutocompleteOpen?: React.RefObject; /** * Called when the user clicks an existing link in the editor. The link * extension runs with `openOnClick: false` (a chat composer must not @@ -155,6 +157,7 @@ export function useRichTextEditor({ onSubmit, onEditLastOwnMessage, isAutocompleteOpen, + isChannelAutocompleteOpen, onEditLink, onLinkSelectionChange, onLinkShortcut, @@ -315,6 +318,9 @@ export function useRichTextEditor({ addKeyboardShortcuts() { return { Enter: ({ editor: ed }) => { + // Channel queries close immediately when invalidated. Let the + // React wrapper select before splitBlock changes that query. + if (isChannelAutocompleteOpen?.current) return true; if (isAutocompleteOpen?.current) return false; if (!onSubmitRef.current) return false; diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6286ebfb56b..d4926a7c7e5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -250,6 +250,10 @@ function MessageComposerImpl({ extractMentionPubkeysRef.current = mentions.extractMentionPubkeys; ownerPubkeyRef.current = ownerPubkey; const isAutocompleteOpenRef = React.useRef(false); + const isChannelAutocompleteOpenRef = React.useRef(false); + React.useLayoutEffect(() => { + isChannelAutocompleteOpenRef.current = channelLinks.isChannelOpen; + }, [channelLinks.isChannelOpen]); isAutocompleteOpenRef.current = mentions.isMentionOpen || channelLinks.isChannelOpen || @@ -294,6 +298,7 @@ function MessageComposerImpl({ return handler ? handler() : false; }, isAutocompleteOpen: isAutocompleteOpenRef, + isChannelAutocompleteOpen: isChannelAutocompleteOpenRef, onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index 3c4fec68044..1d475da08ac 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -48,6 +48,30 @@ test.beforeEach(async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("general"); }); +for (const selection of ["Enter", "Tab", "click"]) { + test(`channel selection by ${selection} preserves subsequent typing`, async ({ + page, + }) => { + const input = page.getByTestId("message-input"); + // This prefix matches only general; "#gen" also matches agents. + await input.fill("#gener"); + const option = page.getByRole("button", { + name: "#general stream", + exact: true, + }); + await expect(option).toBeVisible(); + if (selection === "click") { + await option.click(); + } else { + await input.press(selection); + } + await expect(input).toHaveText("#general "); + await expect(input.locator("p")).toHaveCount(1); + await page.keyboard.type("hello"); + await expect(input).toHaveText("#general hello"); + }); +} + test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({ page, }) => {