diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bb26f558802..4de46c9c86f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/channel-shared-header-backdrop.spec.ts", "**/auxiliary-pane-close-visibility.spec.ts", "**/channel-composer-overflow.spec.ts", + "**/channel-autocomplete.spec.ts", "**/badge.spec.ts", "**/channel-browser.spec.ts", "**/channel-add-screenshots.spec.ts", diff --git a/desktop/src/features/messages/lib/useChannelLinks.ts b/desktop/src/features/messages/lib/useChannelLinks.ts index 4b703f53cd6..c366d0935a7 100644 --- a/desktop/src/features/messages/lib/useChannelLinks.ts +++ b/desktop/src/features/messages/lib/useChannelLinks.ts @@ -124,6 +124,16 @@ export function useChannelLinks() { clearTimeout(debounceTimerRef.current); } + // A cleared edit must release Enter immediately for delete confirmation. + // Keep nonempty queries debounced: Enter can split a valid query's block + // before the composer's autocomplete handler accepts the suggestion. + if (value.length === 0) { + debounceTimerRef.current = null; + setChannelQuery(null); + setChannelSelectedIndex(0); + return; + } + debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = null; const channel = detectPrefixQuery( diff --git a/desktop/tests/e2e/channel-autocomplete.spec.ts b/desktop/tests/e2e/channel-autocomplete.spec.ts new file mode 100644 index 00000000000..47e9977bb22 --- /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"; + +// Keep the real-editor counterexamples from #7874 covered alongside #6733. +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"); + await expect(page.getByTestId("message-input")).toBeFocused(); + 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 before submitting`, async ({ + page, + }) => { + const input = await openComposer(page, surface); + await input.pressSequentially("#gener"); + await expect(channelSuggestion(page)).toBeVisible(); + await input.press("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(); + + // The next Enter submits exactly once through the mock IPC boundary. + 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(); + }); +} diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts index b492b40ee2a..c1607bbf1ba 100644 --- a/desktop/tests/e2e/empty-edit-delete.spec.ts +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -38,17 +38,32 @@ async function submitEmptyEdit( const input = page.getByTestId("message-input"); await expect(input).not.toBeEmpty({ timeout: 5_000 }); await input.click(); - await page.keyboard.press("ControlOrMeta+A"); - await page.keyboard.press("Backspace"); + await expect( + page.getByRole("button", { name: "#general stream", exact: true }), + ).toBeVisible(); + // Keep clear -> Enter inside the channel picker's dismissal debounce, + // regardless of the runner's speed. Drive the real editor, not its hooks. + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1_000)); + try { + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Backspace"); + await expect(input).toBeEmpty(); + await page.keyboard.press("Enter"); + } finally { + await page.clock.resume(); + } + await expect(page.getByRole("alertdialog")).toHaveCount(1); await expect(input).toBeEmpty(); - await page.keyboard.press("Enter"); } test.beforeEach(async ({ page }) => { + await page.clock.install(); await installMockBridge(page); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); + // Let the composer's initial autofocus settle before opening a row menu. + await expect(page.getByTestId("message-input")).toBeFocused(); await expect .poll(() => page.evaluate(