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:
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>;
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user