feat(messaging-ui): Inbox domain — unified work items + mail (contract + UI + mock)

Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components:
- InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply +
  optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter
- hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose
- <Inbox> (filter tabs, unified list, detail pane), <MailReader> (HTML in a
  sandboxed iframe + reply), compose modal (in-app / email); themeable styles
- MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads +
  directory) shipped from ./adapters/mock-inbox
- 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:48:41 +05:30
parent d02cd152de
commit 2f24a95ef4
11 changed files with 958 additions and 2 deletions
@@ -0,0 +1,241 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useCompose, useInbox, useMailThread } from './hooks';
import type { InboxItem, InboxState, MailPerson } from './types';
const FILTERS: { value: InboxState; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'SNOOZED', label: 'Snoozed' },
{ value: 'DONE', label: 'Done' },
{ value: 'ARCHIVED', label: 'Archived' },
];
const KIND_LABEL: Record<string, string> = {
MAIL: 'Mail',
MENTION: 'Mention',
NEEDS_REPLY: 'Needs reply',
SYSTEM_ALERT: 'Alert',
SUPPORT_UPDATE: 'Support',
};
const timeOf = (iso?: string): string => {
if (!iso) return '';
const d = new Date(iso);
return Number.isNaN(+d) ? '' : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
/** The unified inbox: work items + mail in one list; click a threaded row to read + reply. */
export function Inbox() {
const [filter, setFilter] = useState<InboxState>('OPEN');
const { items, loading, error, transition, refetch } = useInbox(filter);
const compose = useCompose();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [composing, setComposing] = useState(false);
useEffect(() => {
if (selectedId && items.some((i) => i.id === selectedId)) return;
setSelectedId(items[0]?.id ?? null);
}, [items, selectedId]);
const selected = items.find((i) => i.id === selectedId) ?? null;
return (
<div className="miu-inbox">
<div className="miu-inbox-bar">
<div className="miu-inbox-filters">
{FILTERS.map((f) => (
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
{f.label}
</button>
))}
</div>
{compose.supported ? (
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
New message
</button>
) : null}
</div>
<div className="miu-inbox-body">
<aside className="miu-inbox-list">
{loading && items.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here you&apos;re all caught up 🎉</div> : null}
{items.map((it) => (
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
<span className="miu-inbox-main">
<span className="miu-inbox-row-top">
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
<span className="miu-inbox-title">{it.title}</span>
</span>
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
</span>
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
</button>
))}
</aside>
<section className="miu-inbox-detail">
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
</section>
</div>
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
</div>
);
}
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
return (
<div className="miu-detail">
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
<div className="miu-detail-actions">
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
</div>
) : null}
{item.threadId ? (
<MailReader threadId={item.threadId} subject={item.title} />
) : (
<div className="miu-detail-body">
<div className="miu-detail-title">{item.title}</div>
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
</div>
)}
</div>
);
}
/** 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 [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
async function submit(e: FormEvent): Promise<void> {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
setDraft('');
setSending(true);
try {
await reply(text);
} catch {
setDraft(text);
} finally {
setSending(false);
}
}
return (
<div className="miu-mail">
<header className="miu-mail-head">{subject || '(no subject)'}</header>
<div className="miu-mail-body">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{messages.map((m) => (
<article key={m.id} className="miu-mail-msg">
<div className="miu-mail-meta">
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
<span>{timeOf(m.at)}</span>
</div>
{m.html ? (
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
) : (
<div className="miu-mail-text">{m.text}</div>
)}
</article>
))}
</div>
<form className="miu-composer" onSubmit={submit}>
<div className="miu-composer-row">
<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>
</div>
</form>
</div>
);
}
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
const compose = useCompose();
const [mode, setMode] = useState<'internal' | 'external'>('internal');
const [recipient, setRecipient] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [q, setQ] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy;
async function send(): Promise<void> {
if (!canSend) return;
setBusy(true);
setErr(null);
try {
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim());
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim());
onSent();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="miu-modal-overlay" onMouseDown={onClose}>
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
<div className="miu-modal-head">
<span>New message</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close"></button>
</div>
<div className="miu-modal-body">
<div className="miu-compose-modes">
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
</div>
{mode === 'internal' ? (
<div className="miu-field">
<span className="miu-field-lbl">To (person)</span>
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
<div className="miu-people">
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p: MailPerson) => (
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
<span>{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
</div>
) : (
<div className="miu-field">
<span className="miu-field-lbl">To (email)</span>
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
</div>
)}
<div className="miu-field">
<span className="miu-field-lbl">Subject</span>
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
</div>
<div className="miu-field">
<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>
{err ? <div className="miu-empty miu-error">{err}</div> : null}
</div>
<div className="miu-modal-foot">
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
</div>
</div>
</div>
);
}