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);