diff --git a/docs/buzz-help.md b/docs/buzz-help.md index de80aa28d6..0c868d13d4 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -187,6 +187,10 @@ that agent in Buzz; they do not notify the external app through this connection. ⌘1–9 switch communities, and ⇧Esc marks everything read. If a plugin uses the same key as another shortcut, Settings → Shortcuts marks it "Also used by"; change one of them to choose which fires. +- **Send key**: choose **Settings → Appearance → Conversations → Send messages + with**. Enter sends by default. Choose Cmd+Enter (Ctrl+Enter on Windows/Linux) + to make Enter add a new line instead. Shift+Enter always adds a new line. + This choice is saved on this device and applies to messages and thread replies. - **Spelling**: in the Mac app or Safari, the message box underlines misspellings but does not correct them unless you turn on **Settings → Appearance → Allow automatic spelling correction**. Correct Spelling diff --git a/src/app/AppearanceSettings.test.tsx b/src/app/AppearanceSettings.test.tsx index 01cc368dbc..e7c0703101 100644 --- a/src/app/AppearanceSettings.test.tsx +++ b/src/app/AppearanceSettings.test.tsx @@ -1,3 +1,7 @@ +import { + messageSendKeyPreference, + setMessageSendKeyPreference, +} from "../features/messages/send-key-preference"; // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { @@ -43,6 +47,7 @@ afterEach(() => { setThreadPlacementPreference("side"); setFollowUpBehaviorPreference("immediate"); setMessageAutocorrectPreference(false); + setMessageSendKeyPreference("enter"); localStorage.clear(); vi.unstubAllGlobals(); }); @@ -521,3 +526,61 @@ it.each([ ).toBe(shown); }, ); + +it.each([ + ["MacIntel", "Cmd+Enter"], + ["Win32", "Ctrl+Enter"], +])( + "saves the %s send-key choice and observes other windows", + async (platform, label) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + const user = userEvent.setup(); + render(preferencesPage()); + expect( + screen.getByRole("combobox", { name: "Send messages with" }), + ).toHaveTextContent("Enter"); + await user.click( + screen.getByRole("combobox", { name: "Send messages with" }), + ); + // Base UI mounts/exposes the popup asynchronously after opening it. + await user.click(await screen.findByRole("option", { name: label })); + expect(messageSendKeyPreference()).toBe("mod-enter"); + expect(localStorage.getItem("buzz-message-send-key.v1")).toBe("mod-enter"); + act(() => { + localStorage.setItem("buzz-message-send-key.v1", "enter"); + window.dispatchEvent( + new StorageEvent("storage", { key: "buzz-message-send-key.v1" }), + ); + }); + expect( + screen.getByRole("combobox", { name: "Send messages with" }), + ).toHaveTextContent("Enter"); + expect(messageSendKeyPreference()).toBe("enter"); + }, +); + +it("keeps an unsaved send-key choice across remounts and retries saving", async () => { + const write = vi + .spyOn(Storage.prototype, "setItem") + .mockImplementation(() => { + throw new Error("denied"); + }); + setMessageSendKeyPreference("mod-enter"); + const view = render(preferencesPage(), { wrapper: ToastProvider }); + expect(messageSendKeyPreference()).toBe("mod-enter"); + expect( + await screen.findByRole("button", { name: "Retry saving send key" }), + ).toBeInTheDocument(); + view.unmount(); + render(preferencesPage(), { wrapper: ToastProvider }); + write.mockRestore(); + fireEvent.click( + await screen.findByRole("button", { name: "Retry saving send key" }), + ); + expect(localStorage.getItem("buzz-message-send-key.v1")).toBe("mod-enter"); + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Retry saving send key" }), + ).not.toBeInTheDocument(), + ); +}); diff --git a/src/app/AppearanceSettings.tsx b/src/app/AppearanceSettings.tsx index 715883b057..82c802c1b8 100644 --- a/src/app/AppearanceSettings.tsx +++ b/src/app/AppearanceSettings.tsx @@ -1,3 +1,8 @@ +import { + setMessageSendKeyPreference, + useMessageSendKeyPreference, +} from "../features/messages/send-key-preference"; +import { isApplePlatform } from "../features/shortcuts/format"; import { Select } from "../shared/design-system/ui/Select"; import { ACCENTS, accentButtonStyle } from "../shared/theme/accent"; @@ -46,6 +51,10 @@ export function AppearanceSettings({ /** Only Mac WebKit applies the composer's autocorrect attribute. */ autocorrectAvailable?: boolean; }) { + const sendKey = useMessageSendKeyPreference(); + const sendChord = isApplePlatform(navigator.platform) + ? "Cmd+Enter" + : "Ctrl+Enter"; const followUp = useFollowUpBehaviorPreference(); const autocorrect = useMessageAutocorrectPreference(); const { placement, unsaved } = useThreadPlacementPreference(); @@ -238,6 +247,30 @@ export function AppearanceSettings({
+ { + if (value === "enter" || value === "mod-enter") + setMessageSendKeyPreference(value); + }} + /> + } + /> )} + {active && sendKey.unsaved && ( + + + + )} {active && followUp.unsaved && ( { await kit.conversation(delivery({ type: "mention", event }), "thread"), ).toBeUndefined(); const workflow = message("3", "@Ampy go", { + // Workflow-shaped messages must not inherit the fixture's owner signer. + pubkey: mallory, tags: [ ["h", channel], ["buzz:workflow", "w"], diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index d73478f6db..25992a3eb0 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -1,3 +1,4 @@ +import { setMessageSendKeyPreference } from "./send-key-preference"; // @vitest-environment jsdom import { Context } from "@deepseek-ai/cordis"; import { PluginRuntime } from "../../plugins/runtime"; @@ -172,6 +173,7 @@ beforeEach(() => { }); afterEach(() => { cleanup(); + setMessageSendKeyPreference("enter"); for (const owner of owners.splice(0)) owner.dispose(); Reflect.deleteProperty(navigator, "locks"); vi.unstubAllGlobals(); @@ -4733,6 +4735,84 @@ it("sends channel messages and thread replies through real form and keyboard eve expect(h.input()).toHaveValue(""); }); +it.each(["MacIntel", "Win32", "Linux x86_64"])( + "uses the chosen send key on %s without sending line breaks or IME confirmation", + async (platform) => { + vi.spyOn(navigator, "platform", "get").mockReturnValue(platform); + onTestFinished(() => { + vi.restoreAllMocks(); + }); + const h = mount(); + await h.user.type(h.input(), "first"); + act(() => setMessageSendKeyPreference("mod-enter")); + await h.user.keyboard("{Enter}second{Shift>}{Enter}{/Shift}third"); + expect(h.input()).toHaveValue("first\nsecond\nthird"); + const mod = platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true }; + fireEvent.keyDown(h.input(), { key: "Enter", ...mod, repeat: true }); + fireEvent.keyDown(h.input(), { key: "Enter", ...mod, isComposing: true }); + fireEvent.keyDown(h.input(), { key: "Enter", ...mod, keyCode: 229 }); + expect(h.messages.send).not.toHaveBeenCalled(); + fireEvent.keyDown(h.input(), { key: "Enter", ...mod }); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "first\nsecond\nthird", + [], + [], + ), + ); + h.retarget({ threadRootId: "root" }); + await h.user.type(h.input(), "reply{Enter}next"); + expect(h.messages.reply).not.toHaveBeenCalled(); + fireEvent.keyDown(h.input(), { key: "Enter", ...mod }); + await waitFor(() => + expect(h.messages.reply).toHaveBeenCalledExactlyOnceWith( + "channel", + "root", + "reply\nnext", + [], + [], + ), + ); + }, +); + +it("continues lists with Enter but sends them with the chosen modified Enter", async () => { + vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + onTestFinished(() => { + vi.restoreAllMocks(); + }); + const h = mount(); + act(() => setMessageSendKeyPreference("mod-enter")); + await h.user.type(h.input(), "- first{Enter}second"); + expect(h.input().querySelectorAll("ul > li")).toHaveLength(2); + expect(h.messages.send).not.toHaveBeenCalled(); + fireEvent.keyDown(h.input(), { key: "Enter", metaKey: true }); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "- first\n- second", + [], + [], + ), + ); +}); + +it("switches a mounted composer back to Enter-to-send", async () => { + const h = mount(); + act(() => setMessageSendKeyPreference("mod-enter")); + await h.user.type(h.input(), "draft{Enter}next"); + expect(h.messages.send).not.toHaveBeenCalled(); + act(() => setMessageSendKeyPreference("enter")); + await h.user.keyboard("{Enter}"); + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "draft\nnext", + [], + [], + ); +}); + it.each([ ["- ", "ul", "- first\n- second"], ["3. ", "ol", "3. first\n4. second"], diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 783e2cfe66..6e2f6b8ad1 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,3 +1,5 @@ +import { useMessageSendKeyPreference } from "./send-key-preference"; +import { isApplePlatform } from "../shortcuts/format"; import { useChatLinkSharing } from "./useChatLinkSharing"; import { draftGif, @@ -491,6 +493,8 @@ function Composer({ trailingTool, }: MessageComposerProps) { const active = useConversationPresentation(); + const sendKey = useMessageSendKeyPreference(); + const apple = isApplePlatform(navigator.platform); // Previews replace the list on every message; subscribe to this channel's facts. const listed = session.channels?.get || soloConversation ? session.channels : undefined; @@ -3190,15 +3194,19 @@ function Composer({ event.key === "Enter" && !event.shiftKey && !event.altKey && - !event.ctrlKey && - !event.metaKey + (sendKey.behavior === "enter" + ? !event.ctrlKey && !event.metaKey + : apple + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey) ) { event.preventDefault(); // Holding Enter must not send after an empty list item exits. if (event.repeat) return; if ( - activeFormats.includes("bullet_list") || - activeFormats.includes("ordered_list") + sendKey.behavior === "enter" && + (activeFormats.includes("bullet_list") || + activeFormats.includes("ordered_list")) ) { event.currentTarget.insertLineBreak(); return; diff --git a/src/features/messages/send-key-preference.ts b/src/features/messages/send-key-preference.ts new file mode 100644 index 0000000000..6e63235d18 --- /dev/null +++ b/src/features/messages/send-key-preference.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from "react"; + +export type MessageSendKey = "enter" | "mod-enter"; +const preferenceKey = "buzz-message-send-key.v1"; +const changedEvent = "buzz-message-send-key-changed"; +let unsavedPreference: MessageSendKey | undefined; + +/** Device-local send key; Enter-to-send remains the default. */ +export function messageSendKeyPreference(): MessageSendKey { + if (unsavedPreference !== undefined) return unsavedPreference; + try { + return localStorage.getItem(preferenceKey) === "mod-enter" + ? "mod-enter" + : "enter"; + } catch { + return "enter"; + } +} +function subscribe(listener: () => void) { + const storage = (event: StorageEvent) => { + if (event.key !== preferenceKey && event.key !== null) return; + unsavedPreference = undefined; + listener(); + }; + window.addEventListener(changedEvent, listener); + window.addEventListener("storage", storage); + return () => { + window.removeEventListener(changedEvent, listener); + window.removeEventListener("storage", storage); + }; +} +export function useMessageSendKeyPreference() { + const behavior = useSyncExternalStore(subscribe, messageSendKeyPreference); + // Saving the same active choice changes recovery state, not its behavior. + const unsaved = useSyncExternalStore( + subscribe, + () => unsavedPreference !== undefined, + ); + return { behavior, unsaved }; +} +export function setMessageSendKeyPreference(value: MessageSendKey): void { + unsavedPreference = value; + try { + localStorage.setItem(preferenceKey, value); + unsavedPreference = undefined; + } catch { + // Retain the active choice and its recovery state across settings remounts. + } + window.dispatchEvent(new Event(changedEvent)); +} diff --git a/tests/browser/composer-send-key.spec.mjs b/tests/browser/composer-send-key.spec.mjs new file mode 100644 index 0000000000..e1dedb8eaa --- /dev/null +++ b/tests/browser/composer-send-key.spec.mjs @@ -0,0 +1,44 @@ +import { test, expect } from "./source-fixture.mjs"; + +// Native editor line splitting and modified Enter delivery need a real engine; +// platform modifier and IME/repeat matrices stay in the mounted composer tests. +test("the saved send key inserts lines, survives reload, and sends with modified Enter", async ({ + page, +}) => { + await page.goto("/tests/fixtures/link-messages.html"); + // Seed once, not with addInitScript: reload must read the saved preference. + await page.evaluate(() => { + localStorage.setItem("buzz-message-send-key.v1", "mod-enter"); + }); + await page.reload(); + const input = page.getByRole("textbox", { + name: "Message #design", + exact: true, + }); + await input.fill("first"); + await input.press("End"); + await input.press("Enter"); + await page.keyboard.type("second"); + await input.press("Shift+Enter"); + await page.keyboard.type("third"); + await expect + .poll(() => input.evaluate((element) => element.value)) + .toBe("first\nsecond\nthird"); + expect( + await page.evaluate(() => window.linkComposerFixture.sent), + ).toHaveLength(0); + await page.reload(); + await expect + .poll(() => input.evaluate((element) => element.value)) + .toBe("first\nsecond\nthird"); + const apple = await page.evaluate(() => + /Mac|iPhone|iPad/.test(navigator.platform), + ); + await input.press(apple ? "Meta+Enter" : "Control+Enter"); + await expect + .poll(() => + page.evaluate(() => window.linkComposerFixture.sent.at(-1)?.text), + ) + .toBe("first\nsecond\nthird"); + await expect.poll(() => input.evaluate((element) => element.value)).toBe(""); +}); diff --git a/tests/browser/send-key-settings.spec.mjs b/tests/browser/send-key-settings.spec.mjs new file mode 100644 index 0000000000..2b9a1b1bec --- /dev/null +++ b/tests/browser/send-key-settings.spec.mjs @@ -0,0 +1,65 @@ +import { test, expect } from "./fixture.mjs"; +import { + openSettings, + selectSettingsSection, + closeSettings, + chooseColorMode, +} from "./navigation.mjs"; + +test.use({ productionBroker: true, historyCounts: { alpha: 1, beta: 0 } }); + +// The host Settings preference must update a retained composer, with keyboard +// input and choice controls usable in both themes and a narrow enlarged layout. +test("the send-key setting changes a retained composer", async ({ + page, + app, +}) => { + await page.goto(app.origin); + await page.getByRole("button", { name: "Alpha", exact: true }).click(); + const input = page.getByRole("textbox", { + name: "Message #Alpha", + exact: true, + }); + await expect(input).toBeVisible(); + await openSettings(page); + await selectSettingsSection(page, "Appearance"); + const choice = page.getByRole("combobox", { + name: "Send messages with", + exact: true, + }); + const apple = await page.evaluate(() => + /Mac|iPhone|iPad/.test(navigator.platform), + ); + for (const mode of ["Dark", "Light"]) { + await chooseColorMode(page, mode); + await choice.scrollIntoViewIfNeeded(); + await expect(choice).toBeVisible(); + } + await page.setViewportSize({ width: 480, height: 800 }); + for (let step = 0; step < 10; step++) + await page + .getByRole("button", { name: "Increase interface size", exact: true }) + .click(); + await choice.scrollIntoViewIfNeeded(); + await choice.click(); + await page + .getByRole("option", { + name: apple ? "Cmd+Enter" : "Ctrl+Enter", + exact: true, + }) + .click(); + await expect(choice).toContainText(apple ? "Cmd+Enter" : "Ctrl+Enter"); + expect( + await page.evaluate(() => localStorage.getItem("buzz-message-send-key.v1")), + ).toBe("mod-enter"); + await closeSettings(page); + await input.fill("first"); + await input.press("End"); + await input.press("Enter"); + await page.keyboard.type("second"); + await expect + .poll(() => input.evaluate((node) => node.value)) + .toBe("first\nsecond"); + await input.press(apple ? "Meta+Enter" : "Control+Enter"); + await expect.poll(() => input.evaluate((node) => node.value)).toBe(""); +});