diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d1691fb7..26b0604c 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1668,6 +1668,30 @@ export function EmailComposer({ handleSend(false, new Date(scheduleValue).toISOString()); }; + // 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; + 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?.(); + }; + window.addEventListener('keydown', handleSendShortcut); + return () => window.removeEventListener('keydown', handleSendShortcut); + }, []); + const cleanClose = () => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); @@ -1749,7 +1773,7 @@ export function EmailComposer({ }; return ( -
+
{ + 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 6ab51f30..88013cd3 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -178,7 +178,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 e7d17868..54da6148 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -2871,7 +2871,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.'); } @@ -2881,20 +2945,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);