feat(attachments): real upload progress via XHR; SDK 0.1.17 picker

Both adapters uploaded with fetch(), which has no upload-progress event — so the
new picker's progress bar could only ever have been indeterminate. putWithProgress
does the presigned PUT over XHR and reports true byte progress; when a server
sends no content-length the fraction is withheld rather than guessed, and the bar
falls back to indeterminate.

Picks up the attachment picker, the Gmail-style Compose button, and the mail
reply row fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:54:19 +05:30
parent 3f6a5b9b1f
commit 7221bec250
5 changed files with 50 additions and 11 deletions
+3 -3
View File
@@ -11,6 +11,7 @@ import type {
MailPerson,
} from "@insignia/iios-messaging-ui";
import type { DataDoor } from "./crm-messaging-adapter";
import { putWithProgress } from "./upload-put";
const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
@@ -84,12 +85,11 @@ export class CrmInboxAdapter implements InboxAdapter {
});
}
async uploadAttachment(file: File): Promise<MailAttachment> {
async uploadAttachment(file: File, onProgress?: (fraction: number) => void): Promise<MailAttachment> {
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
const mime = mimeForFile(file);
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
await putWithProgress(uploadUrl, file, mime, onProgress);
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
}
+3 -3
View File
@@ -22,6 +22,7 @@ import type {
Unsubscribe,
} from "@insignia/iios-messaging-ui";
import type { MessageSocket, Message as KernelMessage } from "@insignia/iios-kernel-client";
import { putWithProgress } from "./upload-put";
/** The imperative appshell data door (useAppShell().sdk). Typed structurally, not to its class. */
export interface DataDoor {
@@ -169,11 +170,10 @@ export class CrmMessagingAdapter implements MessagingAdapter {
return att ? { ...msg, attachment: att } : msg;
}
async upload(file: File): Promise<Attachment> {
async upload(file: File, onProgress?: (fraction: number) => void): Promise<Attachment> {
const mime = file.type || "application/octet-stream";
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
await putWithProgress(uploadUrl, file, mime, onProgress);
const url = await this.downloadUrl(objectKey, mime);
return { url, mime, name: file.name, contentRef: objectKey, sizeBytes: file.size };
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
/**
* PUT a file to a presigned URL, reporting real byte-level progress.
*
* Deliberately XHR and not fetch: fetch has no upload-progress event at all (request streams are
* still not usable for this in browsers), so a fetch-based upload can only ever show a spinner or a
* made-up percentage. XHR's `upload.onprogress` gives the true figure, which is what the picker's
* progress bar renders.
*/
export async function putWithProgress(
url: string,
file: File,
mime: string,
onProgress?: (fraction: number) => void,
): Promise<void> {
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true);
xhr.setRequestHeader("Content-Type", mime);
xhr.upload.onprogress = (e) => {
// Not every server/proxy reports a total; without one a fraction would be a guess, so we
// stay silent and the UI shows its indeterminate bar instead.
if (e.lengthComputable && e.total > 0) onProgress?.(e.loaded / e.total);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
onProgress?.(1);
resolve();
} else {
reject(new Error(`Upload failed (${xhr.status}).`));
}
};
xhr.onerror = () => reject(new Error("Upload failed — check your connection and try again."));
xhr.onabort = () => reject(new Error("Upload cancelled."));
xhr.send(file);
});
}