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
@@ -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;
}
}