From 0b433790a59fd9b53f6de178c7102caa73c44a0d Mon Sep 17 00:00:00 2001 From: maaz519 Date: Sat, 18 Jul 2026 17:31:22 +0530 Subject: [PATCH] feat(inbox): attachments in mail reader + composer - mail-api: MailMessage carries attachment; reply + sendInternal + sendExternal accept uploaded attachments; mock updated - mail.tsx: MailAttachmentView (inline image or file chip via signed URL), StagedChip; attach button in the reply footer and the New Message composer (multi-file, up to 10); text optional when a file is attached - messenger: file-chip icon uses paperclip (was an unknown name) Co-Authored-By: Claude Opus 4.8 --- src/components/dashboard/mail.tsx | 129 +++++++++++++++++++++---- src/components/dashboard/messenger.tsx | 2 +- src/lib/mail-api.ts | 32 +++--- 3 files changed, 130 insertions(+), 33 deletions(-) diff --git a/src/components/dashboard/mail.tsx b/src/components/dashboard/mail.tsx index 500d4ce..49b7c19 100644 --- a/src/components/dashboard/mail.tsx +++ b/src/components/dashboard/mail.tsx @@ -7,9 +7,10 @@ // compose a new message. HTML bodies render in a sandboxed iframe. // ============================================================ -import { type CSSProperties, useEffect, useState } from "react"; +import { type CSSProperties, useEffect, useRef, useState } from "react"; import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui"; -import { useMailThread, useMailCompose, type MailPerson } from "@/lib/mail-api"; +import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api"; +import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api"; const timeOf = (iso?: string) => { if (!iso) return ""; @@ -22,18 +23,78 @@ const inputStyle: CSSProperties = { background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", }; +function fmtBytes(n: number): string { + if (!n) return ""; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */ +function MailAttachmentView({ att }: { att: MailAttachment }) { + const getUrl = useDownloadUrl(); + const [url, setUrl] = useState(null); + useEffect(() => { + let alive = true; + getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {}); + return () => { alive = false; }; + }, [att.contentRef, att.mimeType, getUrl]); + + const label = att.filename || "Attachment"; + if (isImage(att.mimeType)) { + return url + ? {label} + :
Loading image…
; + } + return ( + + + {label} + {att.sizeBytes > 0 && {fmtBytes(att.sizeBytes)}} + + ); +} + +/** A small staged-file chip shown in a composer before send, with a remove button. */ +function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) { + return ( +
+ + {file.filename} + {fmtBytes(file.sizeBytes)} + +
+ ); +} + /** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */ export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) { const t = useMailThread(threadId); + const upload = useUploadAttachment(); const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); + const [staged, setStaged] = useState(null); + const [uploading, setUploading] = useState(false); + const fileRef = useRef(null); + + async function onPickFile(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setUploading(true); + try { setStaged(await upload(file)); } + catch (err) { onError((err as Error).message); } + finally { setUploading(false); } + } async function reply() { const text = draft.trim(); - if (!text || sending) return; - setDraft(""); setSending(true); - try { await t.reply(text); } - catch (e) { setDraft(text); onError((e as Error).message); } + if ((!text && !staged) || sending) return; + const att = staged ?? undefined; + setDraft(""); setStaged(null); setSending(true); + try { await t.reply(text, att); } + catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); } finally { setSending(false); } } @@ -53,17 +114,25 @@ export function MailReader({ threadId, subject, onError }: { threadId: string; s {m.html ?