From e93dd4411190d9ff432f65702796f3c6cb4c39ae Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Thu, 28 May 2026 11:20:22 -0500 Subject: [PATCH 1/4] fix: report real upload progress; XHR with progress events #333 The Files page UI sat at 0% throughout an upload because uploadBlob() uses fetch(), which does not surface upload progress events. The store set loaded=0 before the call and loaded=file.size after it, so users saw the progress bar jump from 0% straight to 100% on completion -- and on slow connections (or large files) it appeared frozen. Switch uploadBlob() to XHR when the caller passes onProgress or an AbortSignal, so progress events from xhr.upload.onprogress can drive the UI. Callers that don't pass either keep the fetch path so we preserve the existing 401-retry behaviour in authenticatedFetch(). Wire the file store to pass both onProgress (updates uploadProgress in real time) and the existing AbortController's signal (so cancel now actually aborts the network request, not just the post-upload createFileNode step). uploadBlob() is part of IJMAPClient so the signature change is also applied to the demo client (synthesises 0% then 100%). --- lib/demo/demo-client.ts | 10 +++- lib/jmap/client-interface.ts | 11 +++- lib/jmap/client.ts | 101 ++++++++++++++++++++++++++++++----- stores/file-store.ts | 15 +++++- 4 files changed, 121 insertions(+), 16 deletions(-) diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 18e00360..b83c2148 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -494,9 +494,17 @@ export class DemoJMAPClient implements IJMAPClient { // ── Blobs ───────────────────────────────────────────────────── - async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> { + async uploadBlob( + file: File, + opts?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal }, + ): Promise<{ blobId: string; size: number; type: string }> { + if (opts?.signal?.aborted) { + throw new DOMException('Upload aborted', 'AbortError'); + } + opts?.onProgress?.(0, file.size); const blobId = generateDemoId('blob'); this.blobStore.set(blobId, file); + opts?.onProgress?.(file.size, file.size); return { blobId, size: file.size, type: file.type }; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 1cfc8b56..05165ec8 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -169,7 +169,16 @@ export interface IJMAPClient { sendImipCancellation(event: CalendarEvent): Promise; // ── Blobs ───────────────────────────────────────────────────── - uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }>; + uploadBlob( + file: File, + optsOrAccountId?: + | string + | { + accountId?: string; + onProgress?: (loaded: number, total: number) => void; + signal?: AbortSignal; + }, + ): Promise<{ blobId: string; size: number; type: string }>; getBlobDownloadUrl(blobId: string, name?: string, type?: string): string; fetchBlob(blobId: string, name?: string, type?: string): Promise; fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 03524cca..399d2413 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2788,7 +2788,71 @@ export class JMAPClient implements IJMAPClient { } } - async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> { + private xhrUpload( + url: string, + file: File, + onProgress?: (loaded: number, total: number) => void, + signal?: AbortSignal, + ): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Upload aborted', 'AbortError')); + return; + } + const xhr = new XMLHttpRequest(); + xhr.open('POST', url, true); + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); + xhr.setRequestHeader('Authorization', this.authHeader); + xhr.responseType = 'text'; + + const onAbort = () => xhr.abort(); + if (signal) signal.addEventListener('abort', onAbort, { once: true }); + const cleanup = () => signal?.removeEventListener('abort', onAbort); + + if (onProgress) { + // Fire 0% immediately so the UI leaves its initial state even + // before the first network packet flushes. + onProgress(0, file.size); + xhr.upload.onprogress = (ev) => { + // ev.total is only meaningful when lengthComputable; fall back + // to file.size so callers always get a usable denominator. + const total = ev.lengthComputable ? ev.total : file.size; + onProgress(ev.loaded, total); + }; + } + + xhr.onload = () => { + cleanup(); + if (xhr.status >= 200 && xhr.status < 300) { + resolve(xhr.responseText); + } else { + reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`)); + } + }; + xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); }; + xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); }; + + xhr.send(file); + }); + } + + // Signature accepts either the legacy positional accountId string OR + // the options bag introduced for progress / signal so existing + // call-sites keep compiling without touching every plugin. + async uploadBlob( + file: File, + optsOrAccountId?: + | string + | { + accountId?: string; + onProgress?: (loaded: number, total: number) => void; + signal?: AbortSignal; + }, + ): Promise<{ blobId: string; size: number; type: string }> { + const opts = + typeof optsOrAccountId === 'string' + ? { accountId: optsOrAccountId } + : optsOrAccountId ?? {}; if (!this.session) { throw new Error('Not connected. Call connect() first.'); } @@ -2798,20 +2862,33 @@ export class JMAPClient implements IJMAPClient { throw new Error('Upload URL not available'); } - const targetAccountId = accountId || this.accountId; + const targetAccountId = opts.accountId || this.accountId; const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId)); - const response = await this.authenticatedFetch(finalUploadUrl, { - method: 'POST', - headers: { 'Content-Type': file.type || 'application/octet-stream' }, - body: file, - }); - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); + // XHR path: fetch() does not expose upload progress events, so when the + // caller wants progress (or an AbortSignal) we use XMLHttpRequest. The + // fetch path is kept for callers that don't need either, to preserve + // existing 401/retry behaviour through authenticatedFetch(). + let responseText: string; + if (opts.onProgress || opts.signal) { + responseText = await this.xhrUpload( + finalUploadUrl, + file, + opts.onProgress, + opts.signal, + ); + } else { + const response = await this.authenticatedFetch(finalUploadUrl, { + method: 'POST', + headers: { 'Content-Type': file.type || 'application/octet-stream' }, + body: file, + }); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); + } + responseText = await response.text(); } - - const responseText = await response.text(); let result; try { result = JSON.parse(responseText); diff --git a/stores/file-store.ts b/stores/file-store.ts index 115252e2..e643e634 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -333,7 +333,12 @@ export const useFileStore = create((set, get) => ({ try { if (abortController.signal.aborted) return; - const { blobId, type } = await client.uploadBlob(file); + const { blobId, type } = await client.uploadBlob(file, { + signal: abortController.signal, + onProgress: (loaded, total) => { + set({ uploadProgress: { name: file.name, loaded, total, current: 1, totalFiles: 1 } }); + }, + }); if (abortController.signal.aborted) return; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: 1, totalFiles: 1 } }); await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null); @@ -361,7 +366,13 @@ export const useFileStore = create((set, get) => ({ set({ uploadProgress: { name: file.name, loaded: 0, total: file.size, current: i + 1, totalFiles } }); try { - const { blobId, type } = await client.uploadBlob(file); + const idx = i; + const { blobId, type } = await client.uploadBlob(file, { + signal: abortController.signal, + onProgress: (loaded, total) => { + set({ uploadProgress: { name: file.name, loaded, total, current: idx + 1, totalFiles } }); + }, + }); if (abortController.signal.aborted) break; set({ uploadProgress: { name: file.name, loaded: file.size, total: file.size, current: i + 1, totalFiles } }); await client.createFileNode(fullName, blobId, type || file.type || 'application/octet-stream', file.size, null); From 2818a16f06d3a569877ef97fe185e2588d416d35 Mon Sep 17 00:00:00 2001 From: Shuki Vaknin Date: Tue, 26 May 2026 20:01:48 +0300 Subject: [PATCH 2/4] feat(composer): Ctrl+Enter / Cmd+Enter sends the open draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- components/email/email-composer.tsx | 23 +++++++++++++++++++++++ hooks/use-keyboard-shortcuts.ts | 1 + locales/en/common.json | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 90704e69..2401ca5c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -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>(); + 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); diff --git a/hooks/use-keyboard-shortcuts.ts b/hooks/use-keyboard-shortcuts.ts index 0a27942c..0dd8a841 100644 --- a/hooks/use-keyboard-shortcuts.ts +++ b/hooks/use-keyboard-shortcuts.ts @@ -290,5 +290,6 @@ export const KEYBOARD_SHORTCUTS = { ], composer: [ { key: "t", description: "shortcuts.composer.template_picker" }, + { key: "Ctrl/Cmd + Enter", description: "shortcuts.composer.send" }, ], } as const; diff --git a/locales/en/common.json b/locales/en/common.json index 072169df..0468b27e 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1882,7 +1882,8 @@ "expand_collapse": "Expand/collapse thread" }, "composer": { - "template_picker": "Open template picker" + "template_picker": "Open template picker", + "send": "Send email" } }, "threads": { From 7d1fb732907d984d6c3598d95f26e05523f5aad7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 28 May 2026 18:28:07 +0200 Subject: [PATCH 3/4] fix: scope Ctrl/Cmd+Enter send to focused composer --- components/email/email-composer.tsx | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 2401ca5c..0dee84d2 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -854,7 +854,6 @@ export function EmailComposer({ return () => window.removeEventListener('keydown', handleTemplateKey); }, []); - const addFiles = useCallback(async (files: File[]) => { if (!client || files.length === 0) return; @@ -1575,21 +1574,23 @@ 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>(); + // Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose + // draft. Scoped to events whose target lives inside this composer's + // DOM tree — in Pro mode multiple composer tabs can be mounted at + // once (inactive tabs are CSS-hidden, not unmounted), so a window + // listener would otherwise fire every mounted composer's handleSend + // on a single keystroke. handleSend is rebound every render, so we + // route through a ref to keep the listener stable. + const composerRootRef = useRef(null); + const handleSendRef = useRef<((skipAttachmentCheck?: boolean) => Promise) | undefined>(undefined); 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; + const root = composerRootRef.current; + if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return; e.preventDefault(); void handleSendRef.current?.(); }; @@ -1636,7 +1637,7 @@ export function EmailComposer({ }; return ( -
+
Date: Tue, 26 May 2026 19:33:48 +0300 Subject: [PATCH 4/4] fix(email-viewer): stop shattering table cells with word-break: break-word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global rule td, th { word-break: break-word; } was breaking HTML-email tables one glyph per row whenever a column was narrow, especially for Hebrew/Arabic/CJK headers and long English strings. The non-standard `word-break: break-word` keyword behaves like `break-all` in some engines, splitting words at arbitrary character boundaries even when the word would fit if the column auto- expanded. `overflow-wrap: break-word` is already set on body/table, so the rule only needs to add min-content relaxation for cells. `overflow-wrap: anywhere` does exactly that without re-introducing break-all behaviour. Repro: any transactional Hebrew/RTL order-summary email — each header (`מוצר`, `כמות`, `מחיר`) collapses to one glyph per row. After the fix they render on a single line. Closes #341. --- components/email/email-viewer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 1be0569b..210eb578 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -2866,7 +2866,7 @@ export function EmailViewer({ img { max-width: 100% !important; height: auto !important; } a { color: #1a73e8; } table { max-width: 100% !important; table-layout: auto; overflow-wrap: break-word; } - td, th { word-break: break-word; } + td, th { overflow-wrap: anywhere; } pre { white-space: pre-wrap; word-wrap: break-word; } ${wordHtmlCSS} ${darkModeCSS}