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
64 changes: 64 additions & 0 deletions desktop/src/features/messages/lib/codeBlockExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
});
59 changes: 59 additions & 0 deletions desktop/src/features/messages/lib/composerSubmitShortcut.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
112 changes: 29 additions & 83 deletions desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ 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";
import { createComposerLinkPasteHandler } from "./composerMessageLinkNode";
import type { ComposerMessageLinkChannel } from "./useComposerMessageLinks";
Expand Down Expand Up @@ -89,9 +91,11 @@ export type RichTextEditorOptions = {
messageLinkChannels?: readonly ComposerMessageLinkChannel[];
/** 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. */
submitShortcut?: ComposerSubmitShortcut;
/**
* Called on ArrowUp in an empty composer (Slack parity: edit your last
* message). Handled inside ProseMirror's `editorProps.handleKeyDown` — the
Expand Down Expand Up @@ -213,13 +217,17 @@ export function useRichTextEditor({
onEditLink,
onLinkSelectionChange,
onLinkShortcut,
submitShortcut = "enter",
}: RichTextEditorOptions) {
const onUpdateRef = React.useRef(onUpdate);
onUpdateRef.current = onUpdate;

const onSubmitRef = React.useRef(onSubmit);
onSubmitRef.current = onSubmit;

const submitShortcutRef = React.useRef(submitShortcut);
submitShortcutRef.current = submitShortcut;

const onEditLastOwnMessageRef = React.useRef(onEditLastOwnMessage);
onEditLastOwnMessageRef.current = onEditLastOwnMessage;

Expand All @@ -244,7 +252,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,
},
Expand Down Expand Up @@ -353,84 +361,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.
Expand All @@ -439,15 +374,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);
Expand All @@ -456,6 +396,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;
},
};
},
}),
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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";
Expand Down Expand Up @@ -223,9 +224,6 @@ function MessageComposerImpl({
emojiAutocomplete.isEmojiAutocompleteOpen;
const submitMessageRef = React.useRef<() => void>(() => {});
const composerScrollRef = React.useRef<HTMLDivElement>(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`).
const onEditLinkRef = React.useRef<
((info: LinkSelectionInfo) => void) | null
>(null);
Expand All @@ -246,6 +244,7 @@ function MessageComposerImpl({
(replyTarget
? `Reply to ${replyTarget.author} in #${channelName}`
: `Message #${channelName}`));
const composerSubmitShortcut = useComposerSubmitShortcut();
const richText = useRichTextEditor({
placeholder: computedPlaceholder,
editable: !composerDisabled,
Expand All @@ -266,6 +265,7 @@ function MessageComposerImpl({
onEditLink: (info) => onEditLinkRef.current?.(info),
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
submitShortcut: composerSubmitShortcut,
onUpdate: ({ cursor, linkPreviewContent, text }) => {
setComposerContentFromText(text);
setPreviewContent(linkPreviewContent);
Expand Down
Loading
Loading