feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft

Adds the universal "send with the platform modifier" shortcut every
mainstream mail client (Gmail, Outlook, Apple Mail, Proton, Tutanota,
Fastmail, Thunderbird) supports. Closes #343.

Behaviour:

* Window-level keydown listener registered while the composer is
  mounted. Fires when focus is anywhere inside the composer — chip
  inputs, subject, body textarea, or the rich-text contentEditable.
* Plain Enter is untouched; only Enter + Ctrl (Win/Linux) or Cmd
  (macOS) triggers send. Shift/Alt modifiers are ignored so existing
  autocomplete-confirm / chip-commit Enters are not hijacked.
* Routes through a ref so handleSend's per-render rebind doesn't
  re-register the listener every render.
* All existing send-time validation, attachment-warning, draft-save
  and undo-send flows still apply — the shortcut just calls the
  same handleSend() as the toolbar button.
* Listed in the Keyboard Shortcuts dialog under the existing
  Composer section.

Tested:

* Compose -> type body -> Ctrl+Enter -> Outbox.
* Cc/Bcc autocomplete suggestion + Enter still selects (alt-free
  Enter without Ctrl, so the new listener bails).
* Subject input -> Ctrl+Enter -> sends.
* Body Enter without modifier -> newline.
This commit is contained in:
Shuki Vaknin
2026-05-28 18:23:37 +02:00
committed by Linus Rath
parent e93dd44111
commit 2818a16f06
3 changed files with 26 additions and 1 deletions
+23
View File
@@ -854,6 +854,7 @@ export function EmailComposer({
return () => window.removeEventListener('keydown', handleTemplateKey);
}, []);
const addFiles = useCallback(async (files: File[]) => {
if (!client || files.length === 0) return;
@@ -1574,6 +1575,28 @@ export function EmailComposer({
}
};
// Ctrl+Enter (Windows/Linux) / Cmd+Enter (macOS) sends the open
// compose draft — same as every other major mail client. Fires
// even when focus is inside the To/Cc/Bcc chips, subject input,
// body textarea, or the rich-text editor's contentEditable region.
// Plain Enter in the body still inserts a newline; only Enter +
// the platform modifier sends. handleSend is rebound every render,
// so we route through a ref to keep the window listener stable.
const handleSendRef = useRef<(skipAttachmentCheck?: boolean) => Promise<void>>();
handleSendRef.current = handleSend;
useEffect(() => {
const handleSendShortcut = (e: KeyboardEvent) => {
if (e.key !== 'Enter') return;
if (!(e.ctrlKey || e.metaKey)) return;
// Don't hijack autocomplete-confirm or chip-commit Enters.
if (e.altKey || e.shiftKey) return;
e.preventDefault();
void handleSendRef.current?.();
};
window.addEventListener('keydown', handleSendShortcut);
return () => window.removeEventListener('keydown', handleSendShortcut);
}, []);
const cleanClose = () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);