8878ee8c54
- Attachment chips in the mail reader are now clickable: new InboxAdapter downloadAttachment() resolves a short-lived URL, opened in a new tab. - Mail reader scrolls again: convert the .miu-detail/.miu-mail height:100% chain to flex fill so a long thread scrolls within the pane instead of being clipped. MockInboxAdapter implements download. Bump to 0.1.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
5.4 KiB
TypeScript
115 lines
5.4 KiB
TypeScript
import type { InboxAdapter } from '../inbox/adapter';
|
|
import type { InboxItem, InboxState, MailAttachment, 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, 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 || 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 downloadAttachment(attachment: MailAttachment): Promise<string> {
|
|
// Demo: no real bytes — hand back a data URL so the click resolves without a network call.
|
|
return `data:${attachment.mimeType};base64,`;
|
|
}
|
|
|
|
async directory(): Promise<MailPerson[]> {
|
|
return [...PEOPLE];
|
|
}
|
|
|
|
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: attachments?.[0] ?? null }] });
|
|
this.mailFold.unshift(threadId);
|
|
void recipientUserId;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|