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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 17:31:22 +05:30
parent 7982c243c0
commit 0b433790a5
3 changed files with 130 additions and 33 deletions
+106 -13
View File
@@ -7,9 +7,10 @@
// compose a new message. HTML bodies render in a sandboxed iframe. // 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 { 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) => { const timeOf = (iso?: string) => {
if (!iso) return ""; if (!iso) return "";
@@ -22,18 +23,78 @@ const inputStyle: CSSProperties = {
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none", 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<string | null>(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
? <a href={url} target="_blank" rel="noreferrer" style={{ display: "inline-block" }}><img src={url} alt={label} style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, border: "1px solid var(--border)" }} /></a>
: <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target="_blank" rel="noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--text)", textDecoration: "none", maxWidth: 320 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
{att.sizeBytes > 0 && <span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(att.sizeBytes)}</span>}
</a>
);
}
/** A small staged-file chip shown in a composer before send, with a remove button. */
function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) {
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 10px", borderRadius: 999, background: "var(--panel-2)", border: "1px solid var(--border)", fontSize: 13 }}>
<Icon name="paperclip" size={14} />
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.filename}</span>
<span style={{ color: "var(--muted)" }}>{fmtBytes(file.sizeBytes)}</span>
<button onClick={onRemove} title="Remove" style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", padding: 0, lineHeight: 1 }}></button>
</div>
);
}
/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */ /** 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 }) { export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) {
const t = useMailThread(threadId); const t = useMailThread(threadId);
const upload = useUploadAttachment();
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
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() { async function reply() {
const text = draft.trim(); const text = draft.trim();
if (!text || sending) return; if ((!text && !staged) || sending) return;
setDraft(""); setSending(true); const att = staged ?? undefined;
try { await t.reply(text); } setDraft(""); setStaged(null); setSending(true);
catch (e) { setDraft(text); onError((e as Error).message); } try { await t.reply(text, att); }
catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); }
finally { setSending(false); } finally { setSending(false); }
} }
@@ -53,17 +114,25 @@ export function MailReader({ threadId, subject, onError }: { threadId: string; s
</div> </div>
{m.html {m.html
? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} /> ? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} />
: <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>} : m.text
? <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>
: null}
{m.attachment && <div style={{ padding: 12, paddingTop: m.html || m.text ? 0 : 12 }}><MailAttachmentView att={m.attachment} /></div>}
</div> </div>
))} ))}
</div> </div>
<footer style={{ display: "flex", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}> <footer style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}>
{staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
<div style={{ display: "flex", gap: 8 }}>
<input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
<Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
<input <input
value={draft} onChange={(e) => setDraft(e.target.value)} value={draft} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
placeholder="Reply…" style={inputStyle} placeholder="Reply…" style={inputStyle}
/> />
<Btn icon="send" onClick={() => void reply()} disabled={sending || !draft.trim()}>Reply</Btn> <Btn icon="send" onClick={() => void reply()} disabled={sending || (!draft.trim() && !staged)}>Reply</Btn>
</div>
</footer> </footer>
</div> </div>
); );
@@ -72,24 +141,40 @@ export function MailReader({ threadId, subject, onError }: { threadId: string; s
/** Compose a new message — in-app (to a person) or external (to an email). */ /** Compose a new message — in-app (to a person) or external (to an email). */
export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) { export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) {
const compose = useMailCompose(onSent); const compose = useMailCompose(onSent);
const upload = useUploadAttachment();
const [mode, setMode] = useState<"internal" | "external">("internal"); const [mode, setMode] = useState<"internal" | "external">("internal");
const [recipient, setRecipient] = useState(""); const [recipient, setRecipient] = useState("");
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); } }, [open]); useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); setStaged([]); setUploading(false); } }, [open]);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(files.map((f) => upload(f)));
setStaged((s) => [...s, ...uploaded].slice(0, 10));
} catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())); const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy; const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy && !uploading;
async function send() { async function send() {
if (!canSend) return; if (!canSend) return;
setBusy(true); setBusy(true);
try { try {
if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim()); if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim(), staged.length ? staged : undefined);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim()); else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), staged.length ? { attachments: staged } : undefined);
} catch (e) { onError((e as Error).message); } } catch (e) { onError((e as Error).message); }
finally { setBusy(false); } finally { setBusy(false); }
} }
@@ -130,6 +215,14 @@ export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field> <Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field> <Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
<Field label="Attachments">
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
</div>
</Field>
</Modal> </Modal>
); );
} }
+1 -1
View File
@@ -37,7 +37,7 @@ function AttachmentView({ att }: { att: UiAttachment }) {
return ( return (
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer" <a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}> style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}>
<Icon name="file" size={18} /> <Icon name="paperclip" size={18} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span> <span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span> <span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
</a> </a>
+18 -14
View File
@@ -19,11 +19,15 @@ import { isShellConfigured } from "./appshell";
export interface MailThread { export interface MailThread {
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string; threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
} }
export interface MailAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null }
export interface MailMessage { export interface MailMessage {
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; attachment: MailAttachment | null;
} }
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" } export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
/** Shape produced by media-api's useUploadAttachment, passed into a reply/compose. */
export interface OutgoingAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
const SHELL = isShellConfigured(); const SHELL = isShellConfigured();
/* ============================ Thread list ============================ */ /* ============================ Thread list ============================ */
@@ -43,7 +47,7 @@ export function useMailThreads(): MailListData {
/* ============================ One thread ============================ */ /* ============================ One thread ============================ */
export interface MailThreadData { export interface MailThreadData {
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string) => Promise<void>; refetch: () => void; loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string, attachment?: OutgoingAttachment) => Promise<void>; refetch: () => void;
} }
export function useMailThread(threadId: string | null): MailThreadData { export function useMailThread(threadId: string | null): MailThreadData {
@@ -54,9 +58,9 @@ export function useMailThread(threadId: string | null): MailThreadData {
function useLiveThread(threadId: string | null): MailThreadData { function useLiveThread(threadId: string | null): MailThreadData {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" }); const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
const reply = useCallback(async (content: string) => { const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
if (!threadId) return; if (!threadId) return;
await sdk.command("crm.mail.reply", { threadId, content }); await sdk.command("crm.mail.reply", { threadId, content, ...(attachment ? { attachment } : {}) });
q.refetch(); q.refetch();
}, [sdk, threadId, q]); }, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch }; return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
@@ -66,8 +70,8 @@ function useLiveThread(threadId: string | null): MailThreadData {
export interface ComposeData { export interface ComposeData {
directory: MailPerson[]; directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string) => Promise<void>; sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, mirrorToUserId?: string) => Promise<void>; sendExternal: (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => Promise<void>;
} }
export function useMailCompose(onSent: () => void): ComposeData { export function useMailCompose(onSent: () => void): ComposeData {
@@ -75,12 +79,12 @@ export function useMailCompose(onSent: () => void): ComposeData {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 }); const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]); const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string) => { const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => {
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>` }); await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(attachments && attachments.length ? { attachments } : {}) });
onSent(); onSent();
}, [sdk, onSent]); }, [sdk, onSent]);
const sendExternal = useCallback(async (target: string, subject: string, text: string, mirrorToUserId?: string) => { const sendExternal = useCallback(async (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => {
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(mirrorToUserId ? { mirrorToUserId } : {}) }); await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(opts?.mirrorToUserId ? { mirrorToUserId: opts.mirrorToUserId } : {}), ...(opts?.attachments && opts.attachments.length ? { attachments: opts.attachments } : {}) });
onSent(); onSent();
}, [sdk, onSent]); }, [sdk, onSent]);
return { directory, sendInternal, sendExternal }; return { directory, sendInternal, sendExternal };
@@ -106,12 +110,12 @@ const MOCK_THREADS: MailThread[] = [
function useMockThread(threadId: string | null): MailThreadData { function useMockThread(threadId: string | null): MailThreadData {
const [extra, setExtra] = useState<MailMessage[]>([]); const [extra, setExtra] = useState<MailMessage[]>([]);
const base: MailMessage[] = threadId === "mt_1" const base: MailMessage[] = threadId === "mt_1"
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club." }] ? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club.", attachment: null }]
: threadId === "mt_2" : threadId === "mt_2"
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7." }] ? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7.", attachment: null }]
: []; : [];
const reply = useCallback(async (content: string) => { const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content }]); setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content, attachment: attachment ? { contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, filename: attachment.filename } : null }]);
}, []); }, []);
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} }; return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
} }