diff --git a/iios-0.0.0.tgz b/iios-0.0.0.tgz new file mode 100644 index 0000000..970695d Binary files /dev/null and b/iios-0.0.0.tgz differ diff --git a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz index c2ff680..9d33aca 100644 Binary files a/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz and b/packages/iios-messaging-ui/insignia-iios-messaging-ui-0.1.0.tgz differ diff --git a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts index fcee29b..c2603e4 100644 --- a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts +++ b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts @@ -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 { + async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise { 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 { + return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name }; } async directory(): Promise { return [...PEOPLE]; } - async composeInternal(recipientUserId: string, subject: string, text: string): Promise { + async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise { const threadId = `mt_${this.seq++}`; - this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `

${text}

`, text, attachment: null }] }); + this.threads.set(threadId, { subject, messages: [{ id: `m_${this.seq++}`, actorId: 'me', kind: 'EMAIL', at: this.now(), html: `

${text}

`, text, attachment: attachments?.[0] ?? null }] }); this.mailFold.unshift(threadId); void recipientUserId; } - async composeExternal(target: string, subject: string, text: string): Promise { + async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise { // A mock external send has no in-app thread — no-op beyond acknowledging. void target; void subject; void text; + void attachments; } } diff --git a/packages/iios-messaging-ui/src/inbox/adapter.ts b/packages/iios-messaging-ui/src/inbox/adapter.ts index ec976f2..dec547e 100644 --- a/packages/iios-messaging-ui/src/inbox/adapter.ts +++ b/packages/iios-messaging-ui/src/inbox/adapter.ts @@ -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; - /** Reply into an existing mail thread. */ - mailReply(threadId: string, content: string): Promise; + /** Reply into an existing mail thread, optionally with one attachment. */ + mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise; + + // ── 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; // ── Compose (optional) — absent hides the "New message" affordance ── /** People you can compose an in-app message to. */ directory?(): Promise; /** App-to-app mail (no SMTP) to a registered user's in-app inbox. */ - composeInternal?(recipientUserId: string, subject: string, text: string): Promise; + composeInternal?(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise; /** External email (SMTP) to an address. */ - composeExternal?(target: string, subject: string, text: string): Promise; + composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise; } diff --git a/packages/iios-messaging-ui/src/inbox/hooks.ts b/packages/iios-messaging-ui/src/inbox/hooks.ts index 8a84464..b9ab6a2 100644 --- a/packages/iios-messaging-ui/src/inbox/hooks.ts +++ b/packages/iios-messaging-ui/src/inbox/hooks.ts @@ -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; + reply: (content: string, attachment?: MailAttachment) => Promise; + canAttach: boolean; + upload: (file: File) => Promise; 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; - sendExternal: (target: string, subject: string, text: string) => Promise; + sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise; + sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise; + canAttach: boolean; + upload: (file: File) => Promise; } /** 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 }; } diff --git a/packages/iios-messaging-ui/src/inbox/inbox.test.tsx b/packages/iios-messaging-ui/src/inbox/inbox.test.tsx index 88318d8..a9193c7 100644 --- a/packages/iios-messaging-ui/src/inbox/inbox.test.tsx +++ b/packages/iios-messaging-ui/src/inbox/inbox.test.tsx @@ -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(' (rendered)', () => { @@ -55,6 +73,17 @@ describe(' (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')); diff --git a/packages/iios-messaging-ui/src/inbox/inbox.tsx b/packages/iios-messaging-ui/src/inbox/inbox.tsx index 4f78892..213e75f 100644 --- a/packages/iios-messaging-ui/src/inbox/inbox.tsx +++ b/packages/iios-messaging-ui/src/inbox/inbox.tsx @@ -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(null); + const [attaching, setAttaching] = useState(false); + const [attachErr, setAttachErr] = useState(null); + const fileRef = useRef(null); + + async function pick(e: React.ChangeEvent): Promise { + 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 { 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 {m.html ? (