From 3c70c04aee4ad64ce47c1d16b966d0caf3ca9c26 Mon Sep 17 00:00:00 2001 From: John Funge Date: Fri, 31 Jul 2026 12:07:27 -0700 Subject: [PATCH 1/3] Add composer send shortcut preference Signed-off-by: John Funge --- .../lib/composerSubmitShortcut.test.mjs | 46 +++++ .../messages/lib/composerSubmitShortcut.ts | 59 ++++++ .../messages/lib/useRichTextEditor.ts | 190 ++++++++++-------- .../features/messages/ui/MessageComposer.tsx | 6 +- .../settings/ui/KeyboardShortcutsCard.tsx | 127 +++++++++++- 5 files changed, 335 insertions(+), 93 deletions(-) create mode 100644 desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs create mode 100644 desktop/src/features/messages/lib/composerSubmitShortcut.ts diff --git a/desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs b/desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs new file mode 100644 index 00000000000..f90aa313f4b --- /dev/null +++ b/desktop/src/features/messages/lib/composerSubmitShortcut.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const storageKey = "buzz:composer-submit-shortcut:v1"; + +function createStorage(seed = {}) { + const values = new Map(Object.entries(seed)); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +} + +let loadSequence = 0; + +async function loadStore(seed) { + globalThis.window = { localStorage: createStorage(seed) }; + loadSequence += 1; + return import( + `./composerSubmitShortcut.ts?test=${Date.now()}-${loadSequence}` + ); +} + +test("composer submit shortcut defaults to Enter", async () => { + const store = await loadStore(); + assert.equal(store.getComposerSubmitShortcut(), "enter"); +}); + +test("composer submit shortcut loads persisted Mod+Enter preference", async () => { + const store = await loadStore({ [storageKey]: "mod-enter" }); + assert.equal(store.getComposerSubmitShortcut(), "mod-enter"); +}); + +test("composer submit shortcut ignores invalid persisted values", async () => { + const store = await loadStore({ [storageKey]: "spacebar" }); + assert.equal(store.getComposerSubmitShortcut(), "enter"); +}); + +test("composer submit shortcut persists changes", async () => { + const store = await loadStore(); + + store.setComposerSubmitShortcut("mod-enter"); + + assert.equal(store.getComposerSubmitShortcut(), "mod-enter"); + assert.equal(window.localStorage.getItem(storageKey), "mod-enter"); +}); diff --git a/desktop/src/features/messages/lib/composerSubmitShortcut.ts b/desktop/src/features/messages/lib/composerSubmitShortcut.ts new file mode 100644 index 00000000000..43b2cec8189 --- /dev/null +++ b/desktop/src/features/messages/lib/composerSubmitShortcut.ts @@ -0,0 +1,59 @@ +import * as React from "react"; + +export type ComposerSubmitShortcut = "enter" | "mod-enter"; + +const STORAGE_KEY = "buzz:composer-submit-shortcut:v1"; +const DEFAULT_SHORTCUT: ComposerSubmitShortcut = "enter"; + +const listeners = new Set<() => void>(); +let currentShortcut = readShortcut(); + +function isComposerSubmitShortcut( + value: unknown, +): value is ComposerSubmitShortcut { + return value === "enter" || value === "mod-enter"; +} + +function readShortcut(): ComposerSubmitShortcut { + if (typeof window === "undefined") return DEFAULT_SHORTCUT; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return isComposerSubmitShortcut(stored) ? stored : DEFAULT_SHORTCUT; + } catch { + return DEFAULT_SHORTCUT; + } +} + +function emit(): void { + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getComposerSubmitShortcut(): ComposerSubmitShortcut { + return currentShortcut; +} + +export function setComposerSubmitShortcut( + nextShortcut: ComposerSubmitShortcut, +): void { + if (currentShortcut === nextShortcut) return; + currentShortcut = nextShortcut; + try { + window.localStorage.setItem(STORAGE_KEY, nextShortcut); + } catch { + // Persistence is best-effort; the live setting still applies in memory. + } + emit(); +} + +export function useComposerSubmitShortcut(): ComposerSubmitShortcut { + return React.useSyncExternalStore( + subscribe, + getComposerSubmitShortcut, + () => DEFAULT_SHORTCUT, + ); +} diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 4fee2db46f7..9bc9fa6df42 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -34,6 +34,7 @@ import { handleCodeFenceEnter, insertNewlineInCodeBlock, } from "./codeBlockExtensions"; +import type { ComposerSubmitShortcut } from "./composerSubmitShortcut"; import { SpoilerMark } from "./spoilerMark"; function hardBreakLineBounds($from: ResolvedPos) { @@ -82,9 +83,14 @@ export type RichTextEditorOptions = { channelNames?: string[]; /** Known custom-emoji set; used to render `:shortcode:` inline as images. */ customEmoji?: CustomEmoji[]; - /** Called on plain Enter (submit). Handled inside Tiptap's extension system - * so it fires *before* ProseMirror's default splitBlock behaviour. */ + /** Called by the active submit shortcut. Handled inside Tiptap's extension + * system so it fires *before* ProseMirror's default splitBlock behaviour. */ onSubmit?: () => void; + /** + * Which keyboard shortcut submits the composer. Defaults to Enter; when set + * to Mod+Enter, plain Enter inserts a soft newline. + */ + submitShortcut?: ComposerSubmitShortcut; /** * Called on ArrowUp in an empty composer (Slack parity: edit your last * message). Handled inside ProseMirror's `editorProps.handleKeyDown` — the @@ -128,6 +134,83 @@ export type RichTextEditorOptions = { const PASTED_LINK_AT_END_RE = /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i; +function exitListIfEmptyLast(ed: Editor): boolean { + if (!ed.isActive("listItem")) return false; + const { $from } = ed.state.selection; + + // Walk up to find the listItem node (handles nested structures). + let listItemDepth = -1; + for (let d = $from.depth; d >= 1; d--) { + if ($from.node(d).type.name === "listItem") { + listItemDepth = d; + break; + } + } + if (listItemDepth < 1) return false; + + const listItem = $from.node(listItemDepth); + const isEmpty = + listItem.childCount === 1 && listItem.firstChild?.textContent === ""; + if (!isEmpty) return false; + + // Only trigger on the last item in the list. + const listDepth = listItemDepth - 1; + const list = $from.node(listDepth); + const itemIndex = $from.index(listDepth); + if (itemIndex !== list.childCount - 1) return false; + + const { tr, schema } = ed.state; + if (list.childCount === 1) { + // Only item → replace the entire list with an empty paragraph. + const listStart = $from.before(listDepth); + const listEnd = $from.after(listDepth); + const para = schema.nodes.paragraph.create(); + tr.replaceWith(listStart, listEnd, para); + tr.setSelection(TextSelection.near(tr.doc.resolve(listStart + 1))); + } else { + // Multiple items → delete the empty item, insert paragraph after the list, + // and move cursor there. + const itemStart = $from.before(listItemDepth); + const itemEnd = $from.after(listItemDepth); + tr.delete(itemStart, itemEnd); + const listEnd = tr.mapping.map($from.after(listDepth)); + const para = schema.nodes.paragraph.create(); + tr.insert(listEnd, para); + tr.setSelection(TextSelection.near(tr.doc.resolve(listEnd + 1))); + } + ed.view.dispatch(tr); + return true; +} + +function insertComposerSoftNewline( + ed: Editor, + { handleCodeFence }: { handleCodeFence: boolean }, +): boolean { + if (handleCodeFence) { + const fenceResult = handleCodeFenceEnter(ed); + if (fenceResult !== undefined) return fenceResult; + } + if (ed.isActive("codeBlock")) { + return insertNewlineInCodeBlock(ed); + } + // Empty last list item → exit list to paragraph below. + if (exitListIfEmptyLast(ed)) return true; + // Non-empty or non-last list item → split. + if (ed.isActive("listItem")) { + return ed.commands.splitListItem("listItem"); + } + if (ed.isActive("blockquote")) { + // Empty blockquote paragraph → exit the blockquote. + const { $from } = ed.state.selection; + if ($from.parent.textContent === "") { + return ed.commands.lift("blockquote"); + } + // Non-empty → split the paragraph within the blockquote. + return ed.chain().splitBlock().focus().run(); + } + return ed.commands.setHardBreak(); +} + function shouldAppendSpaceAfterPaste(text: string): boolean { const trimmedEnd = text.trimEnd(); if (!trimmedEnd || trimmedEnd.length !== text.length) return false; @@ -205,13 +288,18 @@ export function useRichTextEditor({ onEditLink, onLinkSelectionChange, onLinkShortcut, + submitShortcut, }: RichTextEditorOptions) { + const effectiveSubmitShortcut = submitShortcut ?? "enter"; const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; const onSubmitRef = React.useRef(onSubmit); onSubmitRef.current = onSubmit; + const submitShortcutRef = React.useRef(effectiveSubmitShortcut); + submitShortcutRef.current = effectiveSubmitShortcut; + const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; @@ -235,7 +323,7 @@ export function useRichTextEditor({ { extensions: [ StarterKit.configure({ - // Use hard breaks (Shift+Enter) — Enter submits the message. + // Use hard breaks for composer soft-newline shortcuts. hardBreak: { keepMarks: true, }, @@ -344,84 +432,11 @@ export function useRichTextEditor({ Extension.create({ name: "smartShiftEnter", addKeyboardShortcuts() { - // Exit a list by removing the empty last item and inserting a - // paragraph after the list. Works for both single-item and - // multi-item lists. - const exitListIfEmptyLast = (ed: typeof this.editor): boolean => { - if (!ed.isActive("listItem")) return false; - const { $from } = ed.state.selection; - - // Walk up to find the listItem node (handles nested structures). - let listItemDepth = -1; - for (let d = $from.depth; d >= 1; d--) { - if ($from.node(d).type.name === "listItem") { - listItemDepth = d; - break; - } - } - if (listItemDepth < 1) return false; - - const listItem = $from.node(listItemDepth); - const isEmpty = - listItem.childCount === 1 && - listItem.firstChild?.textContent === ""; - if (!isEmpty) return false; - - // Only trigger on the last item in the list. - const listDepth = listItemDepth - 1; - const list = $from.node(listDepth); - const itemIndex = $from.index(listDepth); - if (itemIndex !== list.childCount - 1) return false; - - const { tr, schema } = ed.state; - if (list.childCount === 1) { - // Only item → replace the entire list with an empty paragraph. - const listStart = $from.before(listDepth); - const listEnd = $from.after(listDepth); - const para = schema.nodes.paragraph.create(); - tr.replaceWith(listStart, listEnd, para); - tr.setSelection( - TextSelection.near(tr.doc.resolve(listStart + 1)), - ); - } else { - // Multiple items → delete the empty item, insert paragraph - // after the list, and move cursor there. - const itemStart = $from.before(listItemDepth); - const itemEnd = $from.after(listItemDepth); - tr.delete(itemStart, itemEnd); - const listEnd = tr.mapping.map($from.after(listDepth)); - const para = schema.nodes.paragraph.create(); - tr.insert(listEnd, para); - tr.setSelection( - TextSelection.near(tr.doc.resolve(listEnd + 1)), - ); - } - ed.view.dispatch(tr); - return true; - }; - return { "Shift-Enter": ({ editor: ed }) => { - if (ed.isActive("codeBlock")) { - return insertNewlineInCodeBlock(ed); - } - // Empty last list item → exit list to paragraph below. - if (exitListIfEmptyLast(ed)) return true; - // Non-empty or non-last list item → split. - if (ed.isActive("listItem")) { - return ed.commands.splitListItem("listItem"); - } - if (ed.isActive("blockquote")) { - // Empty blockquote paragraph → exit the blockquote. - const { $from } = ed.state.selection; - if ($from.parent.textContent === "") { - return ed.commands.lift("blockquote"); - } - // Non-empty → split the paragraph within the blockquote. - return ed.chain().splitBlock().focus().run(); - } - // Default: hard break (StarterKit handles it). - return false; + return insertComposerSoftNewline(ed, { + handleCodeFence: false, + }); }, ArrowDown: ({ editor: ed }) => { // Empty last list item + Down → exit list to paragraph below. @@ -430,15 +445,20 @@ export function useRichTextEditor({ }; }, }), - // Plain Enter → submit the message. This runs inside ProseMirror's - // keymap pipeline so it fires *before* the default splitBlock command, - // preventing the phantom paragraph-split that caused \n\n in messages. + // Composer submit/newline shortcut handling. This runs inside + // ProseMirror's keymap pipeline so handled Enter presses fire *before* + // the default splitBlock command, preventing phantom paragraph splits. Extension.create({ name: "submitOnEnter", addKeyboardShortcuts() { return { Enter: ({ editor: ed }) => { if (isAutocompleteOpen?.current) return false; + if (submitShortcutRef.current === "mod-enter") { + return insertComposerSoftNewline(ed, { + handleCodeFence: true, + }); + } if (!onSubmitRef.current) return false; const fenceResult = handleCodeFenceEnter(ed); @@ -447,6 +467,12 @@ export function useRichTextEditor({ onSubmitRef.current(); return true; }, + "Mod-Enter": () => { + if (submitShortcutRef.current !== "mod-enter") return false; + if (!onSubmitRef.current) return false; + onSubmitRef.current(); + return true; + }, }; }, }), diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5d..5234df1af67 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -1,9 +1,9 @@ import * as React from "react"; - import { EditorContent } from "@tiptap/react"; import { useChannelLinks } from "@/features/messages/lib/useChannelLinks"; import { handleAgentSnapshotPaste } from "@/features/messages/lib/agentSnapshotClipboard"; import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus"; +import { useComposerSubmitShortcut } from "@/features/messages/lib/composerSubmitShortcut"; import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks"; import { useDrafts } from "@/features/messages/lib/useDrafts"; import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; @@ -19,7 +19,6 @@ import { restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; - import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; @@ -55,7 +54,6 @@ import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - import type { MessageComposerProps } from "./MessageComposer.types"; function MessageComposerImpl({ @@ -233,6 +231,7 @@ function MessageComposerImpl({ (replyTarget ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); + const composerSubmitShortcut = useComposerSubmitShortcut(); const richText = useRichTextEditor({ placeholder: computedPlaceholder, @@ -253,6 +252,7 @@ function MessageComposerImpl({ onEditLink: (info) => onEditLinkRef.current?.(info), onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info), onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false, + submitShortcut: composerSubmitShortcut, onUpdate: ({ cursor, text }) => { setComposerContentFromText(text); diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index c71e62d5d9e..f950c08a727 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -3,9 +3,39 @@ import { getPlatformKeys, type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; +import { + setComposerSubmitShortcut, + useComposerSubmitShortcut, + type ComposerSubmitShortcut, +} from "@/features/messages/lib/composerSubmitShortcut"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; +const MOD_ENTER_SHORTCUT: KeyboardShortcut = { + id: "composer-mod-enter", + label: "", + description: "", + keys: "⌘Enter", + keysWindows: "Ctrl+Enter", + category: "Messages", +}; + +function getShortcutForDisplay( + shortcut: KeyboardShortcut, + composerSubmitShortcut: ComposerSubmitShortcut, +): KeyboardShortcut { + if ( + shortcut.id === "send-message" && + composerSubmitShortcut === "mod-enter" + ) { + return { ...shortcut, keys: "⌘Enter", keysWindows: "Ctrl+Enter" }; + } + if (shortcut.id === "new-line" && composerSubmitShortcut === "mod-enter") { + return { ...shortcut, keys: "Enter", keysWindows: "Enter" }; + } + return shortcut; +} + function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { const keys = getPlatformKeys(shortcut); // Split on "+" but keep "+" as a standalone key (e.g. for zoom-in "⌘+") @@ -17,25 +47,96 @@ function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { return ( {parts.map((part) => ( - - {part} - + {part} + ))} + + ); +} + +function InlineKey({ children }: { children: string }) { + return ( + + {children} + + ); +} + +function InlineSendShortcut() { + const keys = getPlatformKeys(MOD_ENTER_SHORTCUT); + const parts = keys === "⌘Enter" ? ["⌘", "Enter"] : keys.split("+"); + + return ( + + {parts.map((part) => ( + {part} ))} ); } +function ComposerSubmitShortcutRow({ + sendWithModEnter, +}: { + sendWithModEnter: boolean; +}) { + return ( + +
+ + When writing a message, press + Enter + to... + +
+ + +
+
+
+ ); +} + export function KeyboardShortcutsCard() { const categories = getShortcutsByCategory(); + const composerSubmitShortcut = useComposerSubmitShortcut(); + const sendWithModEnter = composerSubmitShortcut === "mod-enter"; return (
@@ -45,6 +146,11 @@ export function KeyboardShortcutsCard() { {category} + {category === "Messages" ? ( + + ) : null} {shortcuts.map((shortcut) => (
- + ))} From 072e209753eb10e5e9daf528da138776362302b2 Mon Sep 17 00:00:00 2001 From: John Funge Date: Thu, 20 Aug 2026 08:52:05 -0700 Subject: [PATCH 2/3] Refactor composer newline handling for file size policy Signed-off-by: John Funge --- .../messages/lib/codeBlockExtensions.ts | 64 +++++++++++++ .../messages/lib/useRichTextEditor.ts | 92 ++----------------- .../features/messages/ui/MessageComposer.tsx | 5 +- 3 files changed, 71 insertions(+), 90 deletions(-) diff --git a/desktop/src/features/messages/lib/codeBlockExtensions.ts b/desktop/src/features/messages/lib/codeBlockExtensions.ts index c3490eda45c..cc9c3b1979f 100644 --- a/desktop/src/features/messages/lib/codeBlockExtensions.ts +++ b/desktop/src/features/messages/lib/codeBlockExtensions.ts @@ -67,6 +67,70 @@ export function insertNewlineInCodeBlock(ed: Editor): boolean { .run(); } +export function exitListIfEmptyLast(ed: Editor): boolean { + if (!ed.isActive("listItem")) return false; + const { $from } = ed.state.selection; + + let listItemDepth = -1; + for (let depth = $from.depth; depth >= 1; depth--) { + if ($from.node(depth).type.name === "listItem") { + listItemDepth = depth; + break; + } + } + if (listItemDepth < 1) return false; + + const listItem = $from.node(listItemDepth); + const isEmpty = + listItem.childCount === 1 && listItem.firstChild?.textContent === ""; + if (!isEmpty) return false; + + const listDepth = listItemDepth - 1; + const list = $from.node(listDepth); + if ($from.index(listDepth) !== list.childCount - 1) return false; + + const { tr, schema } = ed.state; + if (list.childCount === 1) { + const listStart = $from.before(listDepth); + tr.replaceWith( + listStart, + $from.after(listDepth), + schema.nodes.paragraph.create(), + ); + tr.setSelection(TextSelection.near(tr.doc.resolve(listStart + 1))); + } else { + tr.delete($from.before(listItemDepth), $from.after(listItemDepth)); + const listEnd = tr.mapping.map($from.after(listDepth)); + tr.insert(listEnd, schema.nodes.paragraph.create()); + tr.setSelection(TextSelection.near(tr.doc.resolve(listEnd + 1))); + } + ed.view.dispatch(tr); + return true; +} + +export function insertComposerSoftNewline( + ed: Editor, + { handleCodeFence }: { handleCodeFence: boolean }, +): boolean { + if (handleCodeFence) { + const fenceResult = handleCodeFenceEnter(ed); + if (fenceResult !== undefined) return fenceResult; + } + if (ed.isActive("codeBlock")) return insertNewlineInCodeBlock(ed); + if (exitListIfEmptyLast(ed)) return true; + if (ed.isActive("listItem")) { + return ed.commands.splitListItem("listItem"); + } + if (ed.isActive("blockquote")) { + const { $from } = ed.state.selection; + if ($from.parent.textContent === "") { + return ed.commands.lift("blockquote"); + } + return ed.chain().splitBlock().focus().run(); + } + return ed.commands.setHardBreak(); +} + export const CodeBlockAfterHardBreak = Extension.create({ name: "codeBlockAfterHardBreak", addInputRules() { diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index a4ba98e3a80..516ee085b09 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -34,8 +34,9 @@ import { buildPreviewUpdate } from "./linkPreviewContent"; import { createLinkInteractionExtension } from "./linkInteractionExtension"; import { CodeBlockAfterHardBreak, + exitListIfEmptyLast, handleCodeFenceEnter, - insertNewlineInCodeBlock, + insertComposerSoftNewline, } from "./codeBlockExtensions"; import type { ComposerSubmitShortcut } from "./composerSubmitShortcut"; import { SpoilerMark } from "./spoilerMark"; @@ -93,10 +94,7 @@ export type RichTextEditorOptions = { /** Called by the active submit shortcut. Handled inside Tiptap's extension * system so it fires *before* ProseMirror's default splitBlock behaviour. */ onSubmit?: () => void; - /** - * Which keyboard shortcut submits the composer. Defaults to Enter; when set - * to Mod+Enter, plain Enter inserts a soft newline. - */ + /** Which keyboard shortcut submits the composer. Defaults to Enter. */ submitShortcut?: ComposerSubmitShortcut; /** * Called on ArrowUp in an empty composer (Slack parity: edit your last @@ -141,83 +139,6 @@ export type RichTextEditorOptions = { const PASTED_LINK_AT_END_RE = /(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i; -function exitListIfEmptyLast(ed: Editor): boolean { - if (!ed.isActive("listItem")) return false; - const { $from } = ed.state.selection; - - // Walk up to find the listItem node (handles nested structures). - let listItemDepth = -1; - for (let d = $from.depth; d >= 1; d--) { - if ($from.node(d).type.name === "listItem") { - listItemDepth = d; - break; - } - } - if (listItemDepth < 1) return false; - - const listItem = $from.node(listItemDepth); - const isEmpty = - listItem.childCount === 1 && listItem.firstChild?.textContent === ""; - if (!isEmpty) return false; - - // Only trigger on the last item in the list. - const listDepth = listItemDepth - 1; - const list = $from.node(listDepth); - const itemIndex = $from.index(listDepth); - if (itemIndex !== list.childCount - 1) return false; - - const { tr, schema } = ed.state; - if (list.childCount === 1) { - // Only item → replace the entire list with an empty paragraph. - const listStart = $from.before(listDepth); - const listEnd = $from.after(listDepth); - const para = schema.nodes.paragraph.create(); - tr.replaceWith(listStart, listEnd, para); - tr.setSelection(TextSelection.near(tr.doc.resolve(listStart + 1))); - } else { - // Multiple items → delete the empty item, insert paragraph after the list, - // and move cursor there. - const itemStart = $from.before(listItemDepth); - const itemEnd = $from.after(listItemDepth); - tr.delete(itemStart, itemEnd); - const listEnd = tr.mapping.map($from.after(listDepth)); - const para = schema.nodes.paragraph.create(); - tr.insert(listEnd, para); - tr.setSelection(TextSelection.near(tr.doc.resolve(listEnd + 1))); - } - ed.view.dispatch(tr); - return true; -} - -function insertComposerSoftNewline( - ed: Editor, - { handleCodeFence }: { handleCodeFence: boolean }, -): boolean { - if (handleCodeFence) { - const fenceResult = handleCodeFenceEnter(ed); - if (fenceResult !== undefined) return fenceResult; - } - if (ed.isActive("codeBlock")) { - return insertNewlineInCodeBlock(ed); - } - // Empty last list item → exit list to paragraph below. - if (exitListIfEmptyLast(ed)) return true; - // Non-empty or non-last list item → split. - if (ed.isActive("listItem")) { - return ed.commands.splitListItem("listItem"); - } - if (ed.isActive("blockquote")) { - // Empty blockquote paragraph → exit the blockquote. - const { $from } = ed.state.selection; - if ($from.parent.textContent === "") { - return ed.commands.lift("blockquote"); - } - // Non-empty → split the paragraph within the blockquote. - return ed.chain().splitBlock().focus().run(); - } - return ed.commands.setHardBreak(); -} - function shouldAppendSpaceAfterPaste(text: string): boolean { const trimmedEnd = text.trimEnd(); if (!trimmedEnd || trimmedEnd.length !== text.length) return false; @@ -296,17 +217,16 @@ export function useRichTextEditor({ onEditLink, onLinkSelectionChange, onLinkShortcut, - submitShortcut, + submitShortcut = "enter", }: RichTextEditorOptions) { - const effectiveSubmitShortcut = submitShortcut ?? "enter"; const onUpdateRef = React.useRef(onUpdate); onUpdateRef.current = onUpdate; const onSubmitRef = React.useRef(onSubmit); onSubmitRef.current = onSubmit; - const submitShortcutRef = React.useRef(effectiveSubmitShortcut); - submitShortcutRef.current = effectiveSubmitShortcut; + const submitShortcutRef = React.useRef(submitShortcut); + submitShortcutRef.current = submitShortcut; const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage); onEditLastOwnMessageRef.current = onEditLastOwnMessage; diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 0a327049ed1..6b01d2284bc 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -224,9 +224,7 @@ function MessageComposerImpl({ emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Set after `useLinkEditor` exists below; the editor's link-click handler - // delegates through this ref to break the hook ordering cycle (the editor - // needs `onEditLink`, but the link editor needs the editor's `richText`). + // Refs break the link-handler / richText initialization cycle. const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null); @@ -248,7 +246,6 @@ function MessageComposerImpl({ ? `Reply to ${replyTarget.author} in #${channelName}` : `Message #${channelName}`)); const composerSubmitShortcut = useComposerSubmitShortcut(); - const richText = useRichTextEditor({ placeholder: computedPlaceholder, editable: !composerDisabled, From 0d974464b64ffd4b863bf4a700da6678009d8f92 Mon Sep 17 00:00:00 2001 From: John Funge Date: Thu, 20 Aug 2026 08:53:30 -0700 Subject: [PATCH 3/3] Keep message composer within size ratchet Signed-off-by: John Funge --- desktop/src/features/messages/ui/MessageComposer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 6b01d2284bc..7d4bdc01fa4 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -224,7 +224,6 @@ function MessageComposerImpl({ emojiAutocomplete.isEmojiAutocompleteOpen; const submitMessageRef = React.useRef<() => void>(() => {}); const composerScrollRef = React.useRef(null); - // Refs break the link-handler / richText initialization cycle. const onEditLinkRef = React.useRef< ((info: LinkSelectionInfo) => void) | null >(null);