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:
@@ -18,6 +18,10 @@
|
|||||||
"types": "./dist/adapters/kernel-client.d.ts",
|
"types": "./dist/adapters/kernel-client.d.ts",
|
||||||
"import": "./dist/adapters/kernel-client.js"
|
"import": "./dist/adapters/kernel-client.js"
|
||||||
},
|
},
|
||||||
|
"./adapters/mock-inbox": {
|
||||||
|
"types": "./dist/adapters/mock-inbox.d.ts",
|
||||||
|
"import": "./dist/adapters/mock-inbox.js"
|
||||||
|
},
|
||||||
"./conformance": {
|
"./conformance": {
|
||||||
"types": "./dist/conformance.d.ts",
|
"types": "./dist/conformance.d.ts",
|
||||||
"import": "./dist/conformance.js"
|
"import": "./dist/conformance.js"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<InboxItem[]>;
|
||||||
|
|
||||||
|
/** Transition a work item's state (Done/Snooze/Archive…). Mail rows are not transitioned. */
|
||||||
|
transition(id: string, state: InboxState): Promise<void>;
|
||||||
|
|
||||||
|
/** Messages of one mail thread (HTML + text parts) for the reader. */
|
||||||
|
mailHistory(threadId: string): Promise<MailMessage[]>;
|
||||||
|
|
||||||
|
/** Reply into an existing mail thread. */
|
||||||
|
mailReply(threadId: string, content: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Compose (optional) — absent hides the "New message" affordance ──
|
||||||
|
/** People you can compose an in-app message to. */
|
||||||
|
directory?(): Promise<MailPerson[]>;
|
||||||
|
/** App-to-app mail (no SMTP) to a registered user's in-app inbox. */
|
||||||
|
composeInternal?(recipientUserId: string, subject: string, text: string): Promise<void>;
|
||||||
|
/** External email (SMTP) to an address. */
|
||||||
|
composeExternal?(target: string, subject: string, text: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
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<InboxItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<void>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One mail thread: history + reply. */
|
||||||
|
export function useMailThread(threadId: string | null): MailThreadState {
|
||||||
|
const adapter = useInboxAdapter();
|
||||||
|
const [messages, setMessages] = useState<MailMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<void>;
|
||||||
|
sendExternal: (target: string, subject: string, text: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<MailPerson[]>([]);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<InboxProvider adapter={new MockInboxAdapter()}>
|
||||||
|
<Inbox />
|
||||||
|
</InboxProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('<Inbox /> (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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<InboxState>('OPEN');
|
||||||
|
const { items, loading, error, transition, refetch } = useInbox(filter);
|
||||||
|
const compose = useCompose();
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||||
|
<div className="miu-inbox">
|
||||||
|
<div className="miu-inbox-bar">
|
||||||
|
<div className="miu-inbox-filters">
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button key={f.value} type="button" className={`miu-tab${filter === f.value ? ' is-active' : ''}`} onClick={() => setFilter(f.value)}>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{compose.supported ? (
|
||||||
|
<button type="button" className="miu-send" onClick={() => setComposing(true)}>
|
||||||
|
New message
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="miu-inbox-body">
|
||||||
|
<aside className="miu-inbox-list">
|
||||||
|
{loading && items.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||||
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
|
{!loading && items.length === 0 ? <div className="miu-empty">Nothing here — you're all caught up 🎉</div> : null}
|
||||||
|
{items.map((it) => (
|
||||||
|
<button key={it.id} type="button" className={`miu-inbox-row${it.id === selectedId ? ' is-active' : ''}`} onClick={() => setSelectedId(it.id)}>
|
||||||
|
<span className="miu-inbox-glyph" aria-hidden="true">{it.kind === 'MAIL' ? '✉' : it.kind === 'MENTION' ? '@' : '•'}</span>
|
||||||
|
<span className="miu-inbox-main">
|
||||||
|
<span className="miu-inbox-row-top">
|
||||||
|
<span className="miu-pill">{KIND_LABEL[it.kind] ?? it.kind}</span>
|
||||||
|
<span className="miu-inbox-title">{it.title}</span>
|
||||||
|
</span>
|
||||||
|
{it.summary ? <span className="miu-inbox-summary">{it.summary}</span> : null}
|
||||||
|
</span>
|
||||||
|
{it.state !== 'OPEN' ? <span className="miu-pill">{it.state.toLowerCase()}</span> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="miu-inbox-detail">
|
||||||
|
{selected ? <Detail item={selected} onTransition={(s) => void transition(selected.id, s)} /> : <div className="miu-empty miu-thread-empty">Select an item to read.</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{composing ? <ComposeModal onClose={() => setComposing(false)} onSent={() => { setComposing(false); refetch(); }} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: InboxState) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="miu-detail">
|
||||||
|
{item.state === 'OPEN' && item.kind !== 'MAIL' ? (
|
||||||
|
<div className="miu-detail-actions">
|
||||||
|
<button type="button" className="miu-tab" onClick={() => onTransition('SNOOZED')}>Snooze</button>
|
||||||
|
<button type="button" className="miu-tab" onClick={() => onTransition('DONE')}>Done</button>
|
||||||
|
<button type="button" className="miu-tab" onClick={() => onTransition('ARCHIVED')}>Archive</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.threadId ? (
|
||||||
|
<MailReader threadId={item.threadId} subject={item.title} />
|
||||||
|
) : (
|
||||||
|
<div className="miu-detail-body">
|
||||||
|
<div className="miu-detail-title">{item.title}</div>
|
||||||
|
{item.summary ? <div className="miu-detail-summary">{item.summary}</div> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
setDraft('');
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await reply(text);
|
||||||
|
} catch {
|
||||||
|
setDraft(text);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="miu-mail">
|
||||||
|
<header className="miu-mail-head">{subject || '(no subject)'}</header>
|
||||||
|
<div className="miu-mail-body">
|
||||||
|
{loading && messages.length === 0 ? <div className="miu-empty">Loading…</div> : null}
|
||||||
|
{error ? <div className="miu-empty miu-error">{error}</div> : null}
|
||||||
|
{messages.map((m) => (
|
||||||
|
<article key={m.id} className="miu-mail-msg">
|
||||||
|
<div className="miu-mail-meta">
|
||||||
|
<span>{m.kind === 'EMAIL' ? 'Email' : 'Reply'}{m.actorId ? ` · ${m.actorId}` : ''}</span>
|
||||||
|
<span>{timeOf(m.at)}</span>
|
||||||
|
</div>
|
||||||
|
{m.html ? (
|
||||||
|
<iframe sandbox="" srcDoc={m.html} title="mail body" className="miu-mail-frame" />
|
||||||
|
) : (
|
||||||
|
<div className="miu-mail-text">{m.text}</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form className="miu-composer" onSubmit={submit}>
|
||||||
|
<div className="miu-composer-row">
|
||||||
|
<input className="miu-input" value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply…" aria-label="Reply" />
|
||||||
|
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>Reply</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
|
||||||
|
const compose = useCompose();
|
||||||
|
const [mode, setMode] = useState<'internal' | 'external'>('internal');
|
||||||
|
const [recipient, setRecipient] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||||
|
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy;
|
||||||
|
|
||||||
|
async function send(): Promise<void> {
|
||||||
|
if (!canSend) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim());
|
||||||
|
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim());
|
||||||
|
onSent();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="miu-modal-overlay" onMouseDown={onClose}>
|
||||||
|
<div className="miu-modal" role="dialog" aria-modal="true" onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
<div className="miu-modal-head">
|
||||||
|
<span>New message</span>
|
||||||
|
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="miu-modal-body">
|
||||||
|
<div className="miu-compose-modes">
|
||||||
|
<button type="button" className={`miu-tab${mode === 'internal' ? ' is-active' : ''}`} onClick={() => { setMode('internal'); setRecipient(''); }}>In-app</button>
|
||||||
|
<button type="button" className={`miu-tab${mode === 'external' ? ' is-active' : ''}`} onClick={() => { setMode('external'); setRecipient(''); }}>Email</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'internal' ? (
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">To (person)</span>
|
||||||
|
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
|
||||||
|
<div className="miu-people">
|
||||||
|
{filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
|
||||||
|
{filtered.map((p: MailPerson) => (
|
||||||
|
<label key={p.id} className={`miu-person${recipient === p.id ? ' is-active' : ''}`}>
|
||||||
|
<input type="radio" name="miu-recipient" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
|
||||||
|
<span>{p.name}</span>
|
||||||
|
<span className="miu-pill">{p.kind}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">To (email)</span>
|
||||||
|
<input className="miu-input" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" aria-label="To email" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">Subject</span>
|
||||||
|
<input className="miu-input" value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" aria-label="Subject" />
|
||||||
|
</div>
|
||||||
|
<div className="miu-field">
|
||||||
|
<span className="miu-field-lbl">Message</span>
|
||||||
|
<textarea className="miu-input miu-textarea" value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={5} aria-label="Message body" />
|
||||||
|
</div>
|
||||||
|
{err ? <div className="miu-empty miu-error">{err}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="miu-modal-foot">
|
||||||
|
<button type="button" className="miu-tab" onClick={onClose}>Cancel</button>
|
||||||
|
<button type="button" className="miu-send" onClick={() => void send()} disabled={!canSend}>{busy ? 'Sending…' : 'Send'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createContext, useContext, type ReactNode } from 'react';
|
||||||
|
import type { InboxAdapter } from './adapter';
|
||||||
|
|
||||||
|
const InboxContext = createContext<InboxAdapter | null>(null);
|
||||||
|
|
||||||
|
export function InboxProvider({ adapter, children }: { adapter: InboxAdapter; children: ReactNode }) {
|
||||||
|
return <InboxContext.Provider value={adapter}>{children}</InboxContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access the host-injected inbox adapter. Throws outside a provider — a missing provider is a
|
||||||
|
* wiring bug, and failing loudly beats a confusing null-deref three layers down. */
|
||||||
|
export function useInboxAdapter(): InboxAdapter {
|
||||||
|
const adapter = useContext(InboxContext);
|
||||||
|
if (!adapter) throw new Error('useInboxAdapter must be used within an <InboxProvider>');
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Domain types for the inbox surface (work items + mail). Zero transport imports.
|
||||||
|
|
||||||
|
export type InboxState = 'OPEN' | 'SNOOZED' | 'DONE' | 'ARCHIVED' | 'CANCELLED' | 'STALE';
|
||||||
|
|
||||||
|
/** A unified inbox row — a work item (mention/needs-reply/alert) OR a mail thread. */
|
||||||
|
export interface InboxItem {
|
||||||
|
id: string;
|
||||||
|
kind: string; // MAIL | MENTION | NEEDS_REPLY | SYSTEM_ALERT | …
|
||||||
|
state: InboxState;
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
priority: string;
|
||||||
|
/** Present when the row opens a conversation/mail thread. */
|
||||||
|
threadId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mail attachment reference (bytes live in storage; the reader resolves a display URL). */
|
||||||
|
export interface MailAttachment {
|
||||||
|
contentRef: string;
|
||||||
|
mimeType: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
filename: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One message in a mail thread — HTML and/or plain text, optionally an attachment. */
|
||||||
|
export interface MailMessage {
|
||||||
|
id: string;
|
||||||
|
actorId: string | null;
|
||||||
|
kind: string;
|
||||||
|
at: string;
|
||||||
|
html: string | null;
|
||||||
|
text: string | null;
|
||||||
|
attachment: MailAttachment | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A person you can compose an in-app message to. */
|
||||||
|
export interface MailPerson {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: 'staff' | 'customer';
|
||||||
|
}
|
||||||
@@ -17,6 +17,13 @@ export { ChannelBrowser } from './components/channel-browser';
|
|||||||
export { Thread } from './components/thread';
|
export { Thread } from './components/thread';
|
||||||
export { ThreadPane } from './components/thread-pane';
|
export { ThreadPane } from './components/thread-pane';
|
||||||
|
|
||||||
|
// ── Inbox domain (work items + mail) ──
|
||||||
|
export { InboxProvider, useInboxAdapter } from './inbox/provider';
|
||||||
|
export { useInbox, useMailThread, useCompose } from './inbox/hooks';
|
||||||
|
export { Inbox, MailReader } from './inbox/inbox';
|
||||||
|
export type { InboxAdapter } from './inbox/adapter';
|
||||||
|
export type { InboxItem, InboxState, MailAttachment, MailMessage, MailPerson } from './inbox/types';
|
||||||
|
|
||||||
export type { MessagingAdapter } from './adapter';
|
export type { MessagingAdapter } from './adapter';
|
||||||
export type { ConversationsState } from './hooks/use-conversations';
|
export type { ConversationsState } from './hooks/use-conversations';
|
||||||
export type { MessagesState, UiMessage } from './hooks/use-messages';
|
export type { MessagesState, UiMessage } from './hooks/use-messages';
|
||||||
|
|||||||
@@ -556,6 +556,296 @@
|
|||||||
border-bottom: 1px solid var(--miu-border);
|
border-bottom: 1px solid var(--miu-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Inbox ── */
|
||||||
|
.miu-inbox {
|
||||||
|
--miu-bg: #0e0e13;
|
||||||
|
--miu-panel: #16161d;
|
||||||
|
--miu-panel-2: #1d1d26;
|
||||||
|
--miu-border: #262631;
|
||||||
|
--miu-text: #e9e9ef;
|
||||||
|
--miu-muted: #9a9aa7;
|
||||||
|
--miu-accent: #fda913;
|
||||||
|
--miu-accent-text: #1a1206;
|
||||||
|
--miu-radius: 12px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
color: var(--miu-text);
|
||||||
|
background: var(--miu-bg);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.miu-inbox-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
}
|
||||||
|
.miu-inbox-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.miu-tab {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
background: var(--miu-panel);
|
||||||
|
color: var(--miu-text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.miu-tab.is-active {
|
||||||
|
background: var(--miu-accent);
|
||||||
|
color: var(--miu-accent-text);
|
||||||
|
border-color: var(--miu-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.miu-inbox-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.miu-inbox-list {
|
||||||
|
width: 340px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid var(--miu-border);
|
||||||
|
background: var(--miu-panel);
|
||||||
|
}
|
||||||
|
.miu-inbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.miu-inbox-row:hover,
|
||||||
|
.miu-inbox-row.is-active {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
}
|
||||||
|
.miu-inbox-glyph {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.miu-inbox-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.miu-inbox-row-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.miu-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.miu-inbox-title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.miu-inbox-summary {
|
||||||
|
color: var(--miu-muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.miu-inbox-detail {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--miu-bg);
|
||||||
|
}
|
||||||
|
.miu-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.miu-detail-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
}
|
||||||
|
.miu-detail-body {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
.miu-detail-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.miu-detail-summary {
|
||||||
|
color: var(--miu-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mail reader */
|
||||||
|
.miu-mail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.miu-mail-head {
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.miu-mail-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.miu-mail-msg {
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
border-radius: var(--miu-radius);
|
||||||
|
background: var(--miu-panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.miu-mail-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
}
|
||||||
|
.miu-mail-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
border: none;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.miu-mail-text {
|
||||||
|
padding: 12px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* compose modal */
|
||||||
|
.miu-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(2, 2, 6, 0.6);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.miu-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 88vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--miu-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: var(--miu-panel);
|
||||||
|
color: var(--miu-text);
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
.miu-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--miu-border);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.miu-modal-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.miu-modal-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-top: 1px solid var(--miu-border);
|
||||||
|
}
|
||||||
|
.miu-compose-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.miu-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.miu-field-lbl {
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--miu-muted);
|
||||||
|
}
|
||||||
|
.miu-textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 90px;
|
||||||
|
}
|
||||||
|
.miu-people {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.miu-person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.miu-person.is-active {
|
||||||
|
background: var(--miu-panel-2);
|
||||||
|
}
|
||||||
|
.miu-person span:nth-of-type(1) {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* misc */
|
/* misc */
|
||||||
.miu-empty {
|
.miu-empty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ export default defineConfig({
|
|||||||
// entry, never reachable from `index`, because it imports vitest, and bundling
|
// entry, never reachable from `index`, because it imports vitest, and bundling
|
||||||
// that into the main barrel would drag a test runner into every consumer's
|
// that into the main barrel would drag a test runner into every consumer's
|
||||||
// production build.
|
// production build.
|
||||||
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
|
entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts', 'src/styles.css'],
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
|
// Types only for the TS entries — styles.css has no .d.ts (and tsc chokes on a .css root file).
|
||||||
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
|
dts: { entry: ['src/index.ts', 'src/adapters/mock.ts', 'src/adapters/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
|
||||||
clean: true,
|
clean: true,
|
||||||
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
|
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user