feat(messaging-ui): attachments in inbox mail — reply + compose upload

InboxAdapter gains uploadAttachment + attachment params on mailReply/
composeInternal/composeExternal. MailReader reply and ComposeModal grow an
attach affordance (paperclip + pending chips); mail history renders sent
attachments. MockInboxAdapter implements upload. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 01:05:14 +05:30
parent 2f24a95ef4
commit 2aa2893973
8 changed files with 214 additions and 34 deletions
BIN
View File
Binary file not shown.
@@ -1,5 +1,5 @@
import type { InboxAdapter } from '../inbox/adapter';
import type { InboxItem, InboxState, MailMessage, MailPerson } from '../inbox/types';
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from '../inbox/types';
const PEOPLE: MailPerson[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
@@ -79,26 +79,31 @@ export class MockInboxAdapter implements InboxAdapter {
return [...(this.threads.get(threadId)?.messages ?? [])];
}
async mailReply(threadId: string, content: string): Promise<void> {
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content, attachment: null }];
if (t) t.messages = [...t.messages, { id: `r_${this.seq++}`, actorId: 'me', kind: 'MESSAGE', at: this.now(), html: null, text: content || null, attachment: attachment ?? null }];
}
async uploadAttachment(file: File): Promise<MailAttachment> {
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
}
async directory(): Promise<MailPerson[]> {
return [...PEOPLE];
}
async composeInternal(recipientUserId: string, subject: string, text: string): Promise<void> {
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
const threadId = `mt_${this.seq++}`;
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: null }] });
this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `<p>${text}</p>`, text, attachment: attachments?.[0] ?? null }] });
this.mailFold.unshift(threadId);
void recipientUserId;
}
async composeExternal(target: string, subject: string, text: string): Promise<void> {
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
// A mock external send has no in-app thread — no-op beyond acknowledging.
void target;
void subject;
void text;
void attachments;
}
}
@@ -1,4 +1,4 @@
import type { InboxItem, InboxState, MailMessage, MailPerson } from './types';
import type { MailAttachment, InboxItem, InboxState, MailMessage, MailPerson } from './types';
/**
* The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy:
@@ -15,14 +15,18 @@ export interface InboxAdapter {
/** Messages of one mail thread (HTML + text parts) for the reader. */
mailHistory(threadId: string): Promise<MailMessage[]>;
/** Reply into an existing mail thread. */
mailReply(threadId: string, content: string): Promise<void>;
/** Reply into an existing mail thread, optionally with one attachment. */
mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void>;
// ── Attachments (optional) — absent hides the attach affordance ──
/** Upload a file to storage, returning a reference to send with a reply/compose. */
uploadAttachment?(file: File): Promise<MailAttachment>;
// ── Compose (optional) — absent hides the "New message" affordance ──
/** People you can compose an in-app message to. */
directory?(): Promise<MailPerson[]>;
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
composeInternal?(recipientUserId: string, subject: string, text: string): Promise<void>;
composeInternal?(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
/** External email (SMTP) to an address. */
composeExternal?(target: string, subject: string, text: string): Promise<void>;
composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
}
+30 -12
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { useInboxAdapter } from './provider';
import type { InboxItem, InboxState, MailMessage, MailPerson } from './types';
import type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './types';
export interface InboxData {
items: InboxItem[];
@@ -57,7 +57,9 @@ export interface MailThreadState {
messages: MailMessage[];
loading: boolean;
error: string | null;
reply: (content: string) => Promise<void>;
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
refetch: () => void;
}
@@ -97,22 +99,31 @@ export function useMailThread(threadId: string | null): MailThreadState {
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const reply = useCallback(
async (content: string) => {
async (content: string, attachment?: MailAttachment) => {
if (!threadId) return;
await adapter.mailReply(threadId, content);
await adapter.mailReply(threadId, content, attachment);
setNonce((n) => n + 1);
},
[adapter, threadId],
);
const upload = useCallback(
async (file: File) => {
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
return adapter.uploadAttachment(file);
},
[adapter],
);
return { messages, loading, error, reply, refetch };
return { messages, loading, error, reply, canAttach: typeof adapter.uploadAttachment === 'function', upload, refetch };
}
export interface ComposeState {
supported: boolean;
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string) => Promise<void>;
sendExternal: (target: string, subject: string, text: string) => Promise<void>;
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
}
/** Compose a new message — in-app (to a person) or external (to an email). */
@@ -138,19 +149,26 @@ export function useCompose(): ComposeState {
}, [adapter]);
const sendInternal = useCallback(
async (recipientUserId: string, subject: string, text: string) => {
async (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => {
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
await adapter.composeInternal(recipientUserId, subject, text);
await adapter.composeInternal(recipientUserId, subject, text, attachments);
},
[adapter],
);
const sendExternal = useCallback(
async (target: string, subject: string, text: string) => {
async (target: string, subject: string, text: string, attachments?: MailAttachment[]) => {
if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter');
await adapter.composeExternal(target, subject, text);
await adapter.composeExternal(target, subject, text, attachments);
},
[adapter],
);
const upload = useCallback(
async (file: File) => {
if (!adapter.uploadAttachment) throw new Error('attachments are not supported by this adapter');
return adapter.uploadAttachment(file);
},
[adapter],
);
return { supported, directory, sendInternal, sendExternal };
return { supported, directory, sendInternal, sendExternal, canAttach: typeof adapter.uploadAttachment === 'function', upload };
}
@@ -34,6 +34,24 @@ describe('mock inbox adapter', () => {
await a.mailReply('mt_welcome', 'thanks!');
expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true);
});
it('reply carries an uploaded attachment', async () => {
const a = new MockInboxAdapter();
const ref = await a.uploadAttachment(new File(['x'], 'plan.pdf', { type: 'application/pdf' }));
expect(ref).toMatchObject({ filename: 'plan.pdf', mimeType: 'application/pdf' });
await a.mailReply('mt_welcome', '', ref);
const last = (await a.mailHistory('mt_welcome')).at(-1)!;
expect(last.attachment?.filename).toBe('plan.pdf');
});
it('composeInternal carries a first attachment onto the new thread', async () => {
const a = new MockInboxAdapter();
const ref = await a.uploadAttachment(new File(['x'], 'quote.png', { type: 'image/png' }));
await a.composeInternal('pp_sofia', 'Quote', 'see attached', [ref]);
const open = await a.listInbox('OPEN');
const row = open.find((i) => i.title === 'Quote')!;
expect((await a.mailHistory(row.threadId!)).at(-1)?.attachment?.filename).toBe('quote.png');
});
});
describe('<Inbox /> (rendered)', () => {
@@ -55,6 +73,17 @@ describe('<Inbox /> (rendered)', () => {
await waitFor(() => expect(screen.getByText('got it')).toBeTruthy());
});
it('attaches a file into a mail reply', async () => {
mount();
fireEvent.click(await screen.findByText('Welcome to the Founders Club'));
const file = new File(['data'], 'roof.pdf', { type: 'application/pdf' });
fireEvent.change(await screen.findByLabelText('Attach file'), { target: { files: [file] } });
// The pending chip shows the file, then Reply sends it.
await screen.findByText(/roof\.pdf/);
fireEvent.click(screen.getByText('Reply'));
await waitFor(() => expect(screen.getAllByText(/roof\.pdf/).length).toBeGreaterThan(0));
});
it('composes an in-app message and it shows in the inbox', async () => {
mount();
fireEvent.click(await screen.findByText('New message'));
+95 -11
View File
@@ -1,6 +1,13 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useCompose, useInbox, useMailThread } from './hooks';
import type { InboxItem, InboxState, MailPerson } from './types';
import type { InboxItem, InboxState, MailAttachment, MailPerson } from './types';
const fmtBytes = (n: number): string => {
if (!n) return '';
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
const FILTERS: { value: InboxState; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
@@ -109,20 +116,42 @@ function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state:
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
const { messages, loading, error, reply } = useMailThread(threadId);
const { messages, loading, error, reply, canAttach, upload } = useMailThread(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [pending, setPending] = useState<MailAttachment | null>(null);
const [attaching, setAttaching] = useState(false);
const [attachErr, setAttachErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setAttaching(true);
setAttachErr(null);
try {
setPending(await upload(file));
} catch (err) {
setAttachErr(err instanceof Error ? err.message : String(err));
} finally {
setAttaching(false);
}
}
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
if ((!text && !pending) || sending) return;
const att = pending;
setDraft('');
setPending(null);
setSending(true);
try {
await reply(text);
await reply(text, att ?? undefined);
} catch {
setDraft(text);
setPending(att);
} finally {
setSending(false);
}
@@ -142,16 +171,34 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
</div>
{m.html ? (
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
) : (
) : m.text ? (
<div className="miu-mail-text">{m.text}</div>
)}
) : null}
{m.attachment ? (
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
) : null}
</article>
))}
</div>
<form className="miu-composer" onSubmit={submit}>
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
{pending ? (
<div className="miu-attach-pending">
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment"></button>
</div>
) : null}
<div className="miu-composer-row">
{canAttach ? (
<>
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
{attaching ? '…' : '📎'}
</button>
</>
) : null}
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>Reply</button>
<button type="submit" className="miu-send" disabled={(!draft.trim() && !pending) || sending}>Reply</button>
</div>
</form>
</div>
@@ -167,17 +214,37 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
const [attaching, setAttaching] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
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() || attachments.length > 0) && !busy && !attaching;
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || attachments.length >= 10) return;
setAttaching(true);
setErr(null);
try {
const ref = await compose.upload(file);
setAttachments((a) => [...a, ref]);
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setAttaching(false);
}
}
async function send(): Promise<void> {
if (!canSend) return;
setBusy(true);
setErr(null);
const atts = attachments.length > 0 ? attachments : undefined;
try {
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim());
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim());
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
onSent();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
@@ -229,6 +296,23 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
<span className="miu-field-lbl">Message</span>
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
</div>
{compose.canAttach ? (
<div className="miu-field">
<span className="miu-field-lbl">Attachments</span>
<div className="miu-attach-list">
{attachments.map((a, i) => (
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment"></button>
</span>
))}
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
{attaching ? 'Uploading…' : '📎 Attach'}
</button>
</div>
</div>
) : null}
{err ? <div className="miu-empty miu-error">{err}</div> : null}
</div>
<div className="miu-modal-foot">
+40
View File
@@ -335,6 +335,46 @@
padding: 0;
line-height: 1;
}
/* inbox mail attachments */
.miu-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
font-size: 12.5px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-mail-msg .miu-attach-chip {
margin-top: 6px;
}
.miu-attach-pending {
display: inline-flex;
align-items: center;
gap: 6px;
}
.miu-attach-x {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
padding: 0 2px;
line-height: 1;
font-size: 12px;
}
.miu-attach-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.miu-suggest {
position: absolute;
left: 12px;