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:
@@ -0,0 +1,104 @@
|
||||
import type { InboxAdapter } from '../inbox/adapter';
|
||||
import type { InboxItem, InboxState, MailMessage, MailPerson } from '../inbox/types';
|
||||
|
||||
const PEOPLE: MailPerson[] = [
|
||||
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
|
||||
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
|
||||
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
|
||||
];
|
||||
|
||||
interface MockMail {
|
||||
subject: string;
|
||||
messages: MailMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory inbox for demos/tests. State is PER INSTANCE. Seeds a few work items (mention,
|
||||
* needs-reply, alert) plus mail threads that fold into the Open view as MAIL rows.
|
||||
*/
|
||||
export class MockInboxAdapter implements InboxAdapter {
|
||||
private seq = 100;
|
||||
private readonly items: InboxItem[];
|
||||
private readonly threads = new Map<string, MockMail>();
|
||||
/** threadIds that surface as standalone MAIL rows (in addition to work items). */
|
||||
private readonly mailFold = ['mt_welcome', 'mt_invoice'];
|
||||
|
||||
constructor(private readonly now: () => string = () => new Date().toISOString()) {
|
||||
const at = this.now();
|
||||
this.items = [
|
||||
{ id: 'in_1', kind: 'MENTION', state: 'OPEN', title: 'Sofia mentioned you', summary: '@you — can you confirm the Henderson scope?', priority: 'HIGH', threadId: 'mt_mention', createdAt: at },
|
||||
{ id: 'in_2', kind: 'NEEDS_REPLY', state: 'OPEN', title: 'Reply needed — Storm response', summary: 'Dan: crew rolling out at 7', priority: 'MEDIUM', threadId: 'mt_storm', createdAt: at },
|
||||
{ id: 'in_3', kind: 'SYSTEM_ALERT', state: 'OPEN', title: 'Ticket TK-204 updated', summary: 'Customer replied on the roof-leak case.', priority: 'LOW', createdAt: at },
|
||||
];
|
||||
this.threads.set('mt_mention', {
|
||||
subject: 'Henderson scope',
|
||||
messages: [{ id: 'm1', actorId: 'pp_sofia', kind: 'EMAIL', at, html: '<p>Can you confirm the <b>Henderson</b> scope by EOD?</p>', text: 'Can you confirm the Henderson scope by EOD?', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_storm', {
|
||||
subject: 'Storm response — East side',
|
||||
messages: [{ id: 'm2', actorId: 'pp_dan', kind: 'EMAIL', at, html: '<p>Crew is rolling out at 7. Confirm the Henderson job?</p>', text: 'Crew rolling out at 7. Confirm the Henderson job?', attachment: null }],
|
||||
});
|
||||
this.threads.set('mt_welcome', {
|
||||
subject: 'Welcome to the Founders Club',
|
||||
messages: [{ id: 'm3', actorId: 'system', kind: 'EMAIL', at, 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 }],
|
||||
});
|
||||
this.threads.set('mt_invoice', {
|
||||
subject: 'Invoice #1042 — Acme Roofing',
|
||||
messages: [{ id: 'm4', actorId: 'cust_acme', kind: 'EMAIL', at, html: '<p>Attached is invoice <b>#1042</b> for the East-side job.</p>', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }],
|
||||
});
|
||||
}
|
||||
|
||||
async listInbox(state?: InboxState): Promise<InboxItem[]> {
|
||||
const showMail = !state || state === 'OPEN';
|
||||
const work = this.items.filter((i) => (state ? i.state === state : true));
|
||||
const mail: InboxItem[] = showMail
|
||||
? this.mailFold.map((tid) => {
|
||||
const t = this.threads.get(tid)!;
|
||||
const last = t.messages[t.messages.length - 1];
|
||||
return {
|
||||
id: `mail:${tid}`,
|
||||
kind: 'MAIL',
|
||||
state: 'OPEN' as InboxState,
|
||||
title: t.subject,
|
||||
...(last?.text ? { summary: last.text } : {}),
|
||||
priority: 'LOW',
|
||||
threadId: tid,
|
||||
createdAt: last?.at ?? this.now(),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return [...mail, ...work];
|
||||
}
|
||||
|
||||
async transition(id: string, state: InboxState): Promise<void> {
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (item) item.state = state;
|
||||
}
|
||||
|
||||
async mailHistory(threadId: string): Promise<MailMessage[]> {
|
||||
return [...(this.threads.get(threadId)?.messages ?? [])];
|
||||
}
|
||||
|
||||
async mailReply(threadId: string, content: string): 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 }];
|
||||
}
|
||||
|
||||
async directory(): Promise<MailPerson[]> {
|
||||
return [...PEOPLE];
|
||||
}
|
||||
|
||||
async composeInternal(recipientUserId: string, subject: string, text: string): 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.mailFold.unshift(threadId);
|
||||
void recipientUserId;
|
||||
}
|
||||
|
||||
async composeExternal(target: string, subject: string, text: string): Promise<void> {
|
||||
// A mock external send has no in-app thread — no-op beyond acknowledging.
|
||||
void target;
|
||||
void subject;
|
||||
void text;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user