diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 4c1bd3f..802d21d 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -18,6 +18,10 @@ "types": "./dist/adapters/kernel-client.d.ts", "import": "./dist/adapters/kernel-client.js" }, + "./adapters/mock-inbox": { + "types": "./dist/adapters/mock-inbox.d.ts", + "import": "./dist/adapters/mock-inbox.js" + }, "./conformance": { "types": "./dist/conformance.d.ts", "import": "./dist/conformance.js" diff --git a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts new file mode 100644 index 0000000..fcee29b --- /dev/null +++ b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts @@ -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(); + /** 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: '

Can you confirm the Henderson scope by EOD?

', 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: '

Crew is rolling out at 7. Confirm the Henderson job?

', 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: '

Thanks for joining the Founders Club. Set up your account to get started.

', 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: '

Attached is invoice #1042 for the East-side job.

', text: 'Attached is invoice #1042 for the East-side job.', attachment: null }], + }); + } + + async listInbox(state?: InboxState): Promise { + 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 { + const item = this.items.find((i) => i.id === id); + if (item) item.state = state; + } + + async mailHistory(threadId: string): Promise { + return [...(this.threads.get(threadId)?.messages ?? [])]; + } + + async mailReply(threadId: string, content: string): 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 }]; + } + + async directory(): Promise { + return [...PEOPLE]; + } + + async composeInternal(recipientUserId: string, subject: string, text: string): 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.mailFold.unshift(threadId); + void recipientUserId; + } + + async composeExternal(target: string, subject: string, text: string): Promise { + // A mock external send has no in-app thread — no-op beyond acknowledging. + void target; + void subject; + void text; + } +} diff --git a/packages/iios-messaging-ui/src/inbox/adapter.ts b/packages/iios-messaging-ui/src/inbox/adapter.ts new file mode 100644 index 0000000..ec976f2 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/adapter.ts @@ -0,0 +1,28 @@ +import type { InboxItem, InboxState, MailMessage, MailPerson } from './types'; + +/** + * The inbox seam. A host implements this; the SDK renders it. Mirrors MessagingAdapter's philosophy: + * `listInbox` returns the UNIFIED feed (work items + mail folded in) so the folding logic lives in + * one place (the adapter, which knows both sources). Compose methods are optional and degrade. + */ +export interface InboxAdapter { + /** The unified inbox: work items + mail threads as rows, filtered by state (mail shows in OPEN). */ + listInbox(state?: InboxState): Promise; + + /** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */ + transition(id: string, state: InboxState): Promise; + + /** 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; + + // ── 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; + /** External email (SMTP) to an address. */ + composeExternal?(target: string, subject: string, text: string): Promise; +} diff --git a/packages/iios-messaging-ui/src/inbox/hooks.ts b/packages/iios-messaging-ui/src/inbox/hooks.ts new file mode 100644 index 0000000..8a84464 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/hooks.ts @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useInboxAdapter } from './provider'; +import type { InboxItem, InboxState, MailMessage, MailPerson } from './types'; + +export interface InboxData { + items: InboxItem[]; + loading: boolean; + error: string | null; + transition: (id: string, state: InboxState) => Promise; + refetch: () => void; +} + +/** The unified inbox for a given filter state. Refetches when the filter changes. */ +export function useInbox(state?: InboxState): InboxData { + const adapter = useInboxAdapter(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let alive = true; + setLoading(true); + adapter + .listInbox(state) + .then((list) => { + if (!alive) return; + setItems(list); + setError(null); + }) + .catch((e: unknown) => { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + setItems([]); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, state, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + const transition = useCallback( + async (id: string, next: InboxState) => { + await adapter.transition(id, next); + setNonce((n) => n + 1); + }, + [adapter], + ); + + return { items, loading, error, transition, refetch }; +} + +export interface MailThreadState { + messages: MailMessage[]; + loading: boolean; + error: string | null; + reply: (content: string) => Promise; + refetch: () => void; +} + +/** One mail thread: history + reply. */ +export function useMailThread(threadId: string | null): MailThreadState { + const adapter = useInboxAdapter(); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + if (!threadId) { + setMessages([]); + setLoading(false); + return; + } + let alive = true; + setLoading(true); + adapter + .mailHistory(threadId) + .then((m) => { + if (!alive) return; + setMessages(m); + setError(null); + }) + .catch((e: unknown) => { + if (alive) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (alive) setLoading(false); + }); + return () => { + alive = false; + }; + }, [adapter, threadId, nonce]); + + const refetch = useCallback(() => setNonce((n) => n + 1), []); + const reply = useCallback( + async (content: string) => { + if (!threadId) return; + await adapter.mailReply(threadId, content); + setNonce((n) => n + 1); + }, + [adapter, threadId], + ); + + return { messages, loading, error, reply, refetch }; +} + +export interface ComposeState { + supported: boolean; + directory: MailPerson[]; + sendInternal: (recipientUserId: string, subject: string, text: string) => Promise; + sendExternal: (target: string, subject: string, text: string) => Promise; +} + +/** Compose a new message — in-app (to a person) or external (to an email). */ +export function useCompose(): ComposeState { + const adapter = useInboxAdapter(); + const supported = typeof adapter.composeInternal === 'function'; + const [directory, setDirectory] = useState([]); + + useEffect(() => { + if (!adapter.directory) return; + let alive = true; + adapter + .directory() + .then((d) => { + if (alive) setDirectory(d); + }) + .catch(() => { + if (alive) setDirectory([]); + }); + return () => { + alive = false; + }; + }, [adapter]); + + const sendInternal = useCallback( + async (recipientUserId: string, subject: string, text: string) => { + if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter'); + await adapter.composeInternal(recipientUserId, subject, text); + }, + [adapter], + ); + const sendExternal = useCallback( + async (target: string, subject: string, text: string) => { + if (!adapter.composeExternal) throw new Error('compose is not supported by this adapter'); + await adapter.composeExternal(target, subject, text); + }, + [adapter], + ); + + return { supported, directory, sendInternal, sendExternal }; +} diff --git a/packages/iios-messaging-ui/src/inbox/inbox.test.tsx b/packages/iios-messaging-ui/src/inbox/inbox.test.tsx new file mode 100644 index 0000000..88318d8 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/inbox.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { InboxProvider } from './provider'; +import { Inbox } from './inbox'; +import { MockInboxAdapter } from '../adapters/mock-inbox'; + +function mount() { + return render( + + + , + ); +} + +describe('mock inbox adapter', () => { + it('unifies work items + mail in the Open view; other states drop mail', async () => { + const a = new MockInboxAdapter(); + const open = await a.listInbox('OPEN'); + expect(open.some((i) => i.kind === 'MAIL')).toBe(true); + expect(open.some((i) => i.kind === 'MENTION')).toBe(true); + const done = await a.listInbox('DONE'); + expect(done.some((i) => i.kind === 'MAIL')).toBe(false); + }); + + it('transition moves a work item out of Open', async () => { + const a = new MockInboxAdapter(); + await a.transition('in_3', 'DONE'); + expect((await a.listInbox('OPEN')).some((i) => i.id === 'in_3')).toBe(false); + expect((await a.listInbox('DONE')).some((i) => i.id === 'in_3')).toBe(true); + }); + + it('mail reply appends to the thread', async () => { + const a = new MockInboxAdapter(); + await a.mailReply('mt_welcome', 'thanks!'); + expect((await a.mailHistory('mt_welcome')).some((m) => m.text === 'thanks!')).toBe(true); + }); +}); + +describe(' (rendered)', () => { + it('lists items and opens a mail thread on click', async () => { + mount(); + // A folded mail row is present. + const welcome = await screen.findByText('Welcome to the Founders Club'); + fireEvent.click(welcome); + // The reader opens with a reply box. + expect(await screen.findByLabelText('Reply')).toBeTruthy(); + }); + + it('replies into a mail thread', async () => { + mount(); + fireEvent.click(await screen.findByText('Welcome to the Founders Club')); + const input = (await screen.findByLabelText('Reply')) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'got it' } }); + fireEvent.click(screen.getByText('Reply')); + await waitFor(() => expect(screen.getByText('got it')).toBeTruthy()); + }); + + it('composes an in-app message and it shows in the inbox', async () => { + mount(); + fireEvent.click(await screen.findByText('New message')); + // pick a recipient + fireEvent.click(await screen.findByText('Sofia Ramirez')); + fireEvent.change(screen.getByLabelText('Subject'), { target: { value: 'Quick q' } }); + fireEvent.change(screen.getByLabelText('Message body'), { target: { value: 'ping' } }); + fireEvent.click(screen.getByText('Send')); + await waitFor(() => expect(screen.getByText('Quick q')).toBeTruthy()); + }); +}); diff --git a/packages/iios-messaging-ui/src/inbox/inbox.tsx b/packages/iios-messaging-ui/src/inbox/inbox.tsx new file mode 100644 index 0000000..4f78892 --- /dev/null +++ b/packages/iios-messaging-ui/src/inbox/inbox.tsx @@ -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 = { + 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('OPEN'); + const { items, loading, error, transition, refetch } = useInbox(filter); + const compose = useCompose(); + const [selectedId, setSelectedId] = useState(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 ( +
+
+
+ {FILTERS.map((f) => ( + + ))} +
+ {compose.supported ? ( + + ) : null} +
+ +
+ + +
+ {selected ? void transition(selected.id, s)} /> :
Select an item to read.
} +
+
+ + {composing ? setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null} +
+ ); +} + +function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) { + return ( +
+ {item.state === 'OPEN' && item.kind !== 'MAIL' ? ( +
+ + + +
+ ) : null} + {item.threadId ? ( + + ) : ( +
+
{item.title}
+ {item.summary ?
{item.summary}
: null} +
+ )} +
+ ); +} + +/** 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 { + e.preventDefault(); + const text = draft.trim(); + if (!text || sending) return; + setDraft(''); + setSending(true); + try { + await reply(text); + } catch { + setDraft(text); + } finally { + setSending(false); + } + } + + return ( +
+
{subject || '(no subject)'}
+
+ {loading && messages.length === 0 ?
Loading…
: null} + {error ?
{error}
: null} + {messages.map((m) => ( +
+
+ {m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''} + {timeOf(m.at)} +
+ {m.html ? ( +