Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 111 additions & 0 deletions desktop/src/features/messages/lib/useChannelLinks.lifecycle.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import { after, afterEach, before, test } from "node:test";
import { JSDOM } from "jsdom";
import * as React from "react";

import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { useChannelLinks } from "./useChannelLinks.ts";

const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});

before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
});

afterEach(async () => {
const { cleanup } = await import("@testing-library/react");
cleanup();
});
after(() => dom.window.close());

const channels = [
{ id: "general", name: "general", channelType: "stream", archivedAt: null },
];

function wrapper({ children }) {
return React.createElement(ChannelNavigationProvider, { channels }, children);
}

function enterEvent() {
return {
key: "Enter",
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
};
}

for (const [name, text, cursor] of [
["replacing the loaded edit", "Edited, not deleted", 19],
["clearing the loaded edit", "", 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");
t.mock.timers.enable({ apis: ["setTimeout"] });
const { result, unmount } = renderHook(useChannelLinks, { wrapper });
act(() => result.current.updateChannelQuery("Welcome to #general", 19));
act(() => t.mock.timers.tick(120));
assert.equal(result.current.isChannelOpen, true);

act(() => result.current.updateChannelQuery(text, cursor));
assert.equal(result.current.isChannelOpen, false);
const event = enterEvent();
assert.deepEqual(result.current.handleChannelKeyDown(event), {
handled: false,
});
assert.equal(event.defaultPrevented, false);
act(() => t.mock.timers.tick(120));
assert.equal(result.current.isChannelOpen, false);
unmount();
});
}

test("clearing a pending query cannot reopen channel completion", async (t) => {
const { act, renderHook } = await import("@testing-library/react");
t.mock.timers.enable({ apis: ["setTimeout"] });
const { result, unmount } = renderHook(useChannelLinks, { wrapper });
act(() => result.current.updateChannelQuery("#gen", 4));
act(() => result.current.updateChannelQuery("", 0));
act(() => t.mock.timers.tick(120));
assert.equal(result.current.isChannelOpen, false);
unmount();
});

test("valid channel completion still debounces and consumes Enter", async (t) => {
const { act, renderHook } = await import("@testing-library/react");
t.mock.timers.enable({ apis: ["setTimeout"] });
const { result, unmount } = renderHook(useChannelLinks, { wrapper });
act(() => result.current.updateChannelQuery("See #gen", 8));
act(() => t.mock.timers.tick(119));
assert.equal(result.current.isChannelOpen, false);
act(() => t.mock.timers.tick(1));
assert.equal(result.current.isChannelOpen, true);
const event = enterEvent();
const selection = result.current.handleChannelKeyDown(event);
assert.equal(selection.handled, true);
assert.equal(event.defaultPrevented, true);
let edit;
act(() => {
edit = result.current.insertChannel(selection.suggestion, 8);
});
assert.deepEqual(edit, {
replaceFromOffset: 4,
replaceToOffset: 8,
insertText: "#general ",
});
assert.equal(result.current.isChannelOpen, false);
unmount();
});
16 changes: 16 additions & 0 deletions desktop/src/features/messages/lib/useChannelLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ export function useChannelLinks() {

if (debounceTimerRef.current !== null) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}

// Losing the #query must close immediately. Debouncing dismissal leaves
// the old suggestion owning Enter after the user replaces/clears an edit.
if (
!detectPrefixQuery(
"#",
value,
cursorPosition,
knownNamesLowerRef.current,
)
) {
setChannelQuery(null);
setChannelSelectedIndex(0);
return;
}

debounceTimerRef.current = setTimeout(() => {
Expand Down
6 changes: 4 additions & 2 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
/**
* Called when the user clicks an existing link in the editor. The link
Expand Down Expand Up @@ -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);
Expand Down
111 changes: 111 additions & 0 deletions desktop/tests/e2e/channel-autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
Loading