diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 813b7b968a..a76cc78406 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -127,6 +127,7 @@ export default defineConfig({ "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", "**/empty-edit-delete.spec.ts", + "**/channel-autocomplete.spec.ts", "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", diff --git a/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs b/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs index e9b4ed928c..67e078e2b9 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs +++ b/desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs @@ -46,7 +46,11 @@ function enterEvent() { for (const [name, text, cursor] of [ ["replacing the loaded edit", "Edited, not deleted", 19], ["clearing the loaded edit", "", 0], - ["moving before the channel reference", "Welcome to #general", 0], + [ + "receiving an update with the cursor before the reference", + "Welcome to #general", + 0, + ], ]) { test(`${name} releases Enter before the debounce expires`, async (t) => { const { act, renderHook } = await import("@testing-library/react"); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 837822f01f..db5a5aea89 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -107,7 +107,7 @@ export type RichTextEditorOptions = { * ArrowUp fall through to normal caret movement. */ onEditLastOwnMessage?: () => boolean; - /** When true, plain Enter is passed through (e.g. to select an autocomplete item). */ + /** When true, plain Enter is reserved for the composer's autocomplete handler. */ isAutocompleteOpen?: React.RefObject; /** * Called when the user clicks an existing link in the editor. The link @@ -315,7 +315,9 @@ export function useRichTextEditor({ addKeyboardShortcuts() { return { Enter: ({ editor: ed }) => { - if (isAutocompleteOpen?.current) return false; + // Block splitBlock without stopping DOM propagation: the + // composer's React handler selects the autocomplete item. + if (isAutocompleteOpen?.current) return true; if (!onSubmitRef.current) return false; const fenceResult = handleCodeFenceEnter(ed); diff --git a/desktop/tests/e2e/channel-autocomplete.spec.ts b/desktop/tests/e2e/channel-autocomplete.spec.ts new file mode 100644 index 0000000000..7ec24e8475 --- /dev/null +++ b/desktop/tests/e2e/channel-autocomplete.spec.ts @@ -0,0 +1,111 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +async function openComposer( + page: Page, + surface: "channel" | "thread" | "forum", +) { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + if (surface === "thread") { + await page.locator('[data-message-id="mock-general-alice"]').hover(); + await page.getByTestId("reply-message-mock-general-alice").click(); + return page + .getByTestId("message-thread-panel") + .getByTestId("message-input"); + } + if (surface === "forum") { + await page.getByTestId("channel-watercooler").click(); + await page.getByRole("button", { name: "Start a new post..." }).click(); + } + return page.getByTestId("message-input"); +} + +function channelSuggestion(page: Page) { + return page.getByRole("button", { name: "#general stream", exact: true }); +} + +for (const surface of ["channel", "thread", "forum"] as const) { + test(`Enter selects a channel in the ${surface} composer without splitting or submitting`, async ({ + page, + }) => { + const input = await openComposer(page, surface); + await input.pressSequentially("#gener"); + await expect(channelSuggestion(page)).toBeVisible(); + await input.press("Enter"); + + // Assert literal text (including the separator), document shape and focus: + // hook-only tests cannot catch ProseMirror splitting before React handles Enter. + await expect.poll(() => input.textContent()).toBe("#general "); + await expect(input.locator("p")).toHaveCount(1); + await expect(input.locator("br")).toHaveCount(0); + await expect(input).toBeFocused(); + await expect(channelSuggestion(page)).toBeHidden(); + + // Autocomplete must relinquish the next Enter to ordinary submission. + await page.keyboard.type("selected channel"); + await input.press("Enter"); + await expect + .poll(() => + page.evaluate(() => { + const content = "#general selected channel"; + const signed = (window.__BUZZ_E2E_SIGNED_EVENTS__ ?? []).filter( + (event) => event.content === content, + ); + if (signed.length) return signed.length; + return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (call) => + call.command === "send_channel_message" && + (call.payload as { content?: string }).content === content, + ).length; + }), + ) + .toBe(1); + }); +} + +for (const key of ["Tab", "Escape", "Shift+Enter"] as const) { + test(`channel autocomplete preserves ${key} behavior`, async ({ page }) => { + const input = await openComposer(page, "channel"); + await input.fill("#gener"); + await expect(channelSuggestion(page)).toBeVisible(); + await input.press(key); + await expect + .poll(() => input.textContent()) + .toBe(key === "Tab" ? "#general " : "#gener"); + await expect(channelSuggestion(page)).toBeHidden(); + await expect(input.locator("p")).toHaveCount(1); + if (key === "Shift+Enter") { + await page.keyboard.type("next line"); + await expect(input).toHaveText("#genernext line"); + await expect(input.locator("br")).toHaveCount(1); + } else { + await expect(input.locator("br")).toHaveCount(0); + } + }); +} + +for (const [prefix, expected] of [ + ["@ali", "@alice "], + [":joy", "😂 "], +] as const) { + test(`Enter still selects ${prefix} autocomplete without splitting`, async ({ + page, + }) => { + const input = await openComposer(page, "channel"); + const dropdown = page.getByTestId( + prefix === "@ali" ? "mention-autocomplete" : "emoji-autocomplete", + ); + await input.fill(prefix); + await expect(dropdown).toBeVisible(); + await input.press("Enter"); + await expect(dropdown).toBeHidden(); + await expect.poll(() => input.textContent()).toBe(expected); + await expect(input.locator("p")).toHaveCount(1); + await expect(input.locator("br")).toHaveCount(0); + await expect(input).toBeFocused(); + }); +}