Merge pull request 'Feat/messaging ui foundation' (#6) from feat/messaging-ui-foundation into dev

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-07-22 18:46:31 +00:00
71 changed files with 7312 additions and 14 deletions
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+22 -1
View File
@@ -1,5 +1,5 @@
import type { IngestInteractionRequest } from '@insignia/iios-contracts';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, SavedItem, LoginResult } from './types';
import type { Message, InboxItem, InboxState, Ticket, TicketState, CallbackRequest, RouteBinding, RouteDecision, AiArtifact, AiJobResult, Meeting, MeetingActionItem, ThreadSummary, DiscoveredThread, SavedItem, LoginResult } from './types';
export interface RestConfig {
serviceUrl: string;
@@ -103,6 +103,27 @@ export class RestClient {
return this.post<{ threadId: string }>('/v1/threads', opts);
}
/**
* Discover threads across your scope matching an opaque metadata filter (e.g. browse public
* channels: `{ membership: 'channel', visibility: 'public' }`). Returns ones you have NOT joined
* too, each flagged `joined`.
*/
async discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]> {
const qs = filter?.metadata
? '?' + Object.entries(filter.metadata).map(([k, v]) => `metadata[${encodeURIComponent(k)}]=${encodeURIComponent(v)}`).join('&')
: '';
const r = await fetch(this.url(`/v1/threads/discover${qs}`), { headers: this.headers() });
if (!r.ok) throw new Error(`discoverThreads ${r.status}`);
return (await r.json()) as DiscoveredThread[];
}
/** Self-leave a thread (e.g. leave a channel). */
async leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }> {
const r = await fetch(this.url(`/v1/threads/${threadId}/me`), { method: 'DELETE', headers: this.headers() });
if (!r.ok) throw new Error(`leaveThread ${r.status}`);
return (await r.json()) as { threadId: string; participantCount: number };
}
/** My saved messages (personal bookmarks), newest first, with thread context. */
async listSaved(): Promise<SavedItem[]> {
const r = await fetch(this.url('/v1/threads/my-annotations?type=save'), { headers: this.headers() });
+9
View File
@@ -64,6 +64,15 @@ export interface MessageEvents {
annotation: (e: AnnotationEvent) => void;
}
/** A discoverable thread from GET /v1/threads/discover — includes ones you have NOT joined. */
export interface DiscoveredThread {
threadId: string;
subject: string | null;
metadata: Record<string, unknown> | null;
participantCount: number;
joined: boolean;
}
/** A "my threads" entry from GET /v1/threads (server-authoritative). */
export interface ThreadSummary {
threadId: string;
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@insignia/iios-messaging-ui",
"version": "0.1.0",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./adapters/mock": {
"types": "./dist/adapters/mock.d.ts",
"import": "./dist/adapters/mock.js"
},
"./adapters/kernel-client": {
"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"
},
"./styles.css": "./dist/styles.css"
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"@insignia/iios-kernel-client": {
"optional": true
}
},
"devDependencies": {
"@insignia/iios-kernel-client": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"jsdom": "^26.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"publishConfig": {
"registry": "https://git.lynkedup.cloud/api/packages/insignia/npm/"
}
}
+83
View File
@@ -0,0 +1,83 @@
import type {
Attachment,
ChannelSummary,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Person,
SendOpts,
Unsubscribe,
} from './types';
/**
* The one seam of this SDK. Hosts implement this; the SDK renders it.
*
* Lifted from lynkeduppro-crm's MessengerData/ThreadData, which already survived
* two implementations (live data-door + mock) — the minimum real evidence that a
* seam is genuine rather than imagined.
*
* Optional methods degrade gracefully: the UI hides the reaction picker when
* `react` is absent, and the attach button when `upload` is absent. That is how one
* component set serves both a full CRM messenger and a stripped-down widget with no
* `mode` prop.
*/
export interface MessagingAdapter {
listConversations(): Promise<Conversation[]>;
openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }>;
history(threadId: string): Promise<Message[]>;
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
/** Returns an unsubscribe fn. Implementations MUST be idempotent on repeat unsubscribe. */
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
sendTyping(threadId: string): void;
markRead(threadId: string, messageId: string): Promise<void>;
/**
* The current user's actor id, or null if not yet known.
*
* MUST NOT be inferred from message history. The CRM's bug was exactly that:
* scanning for a sent message meant every message read as not-yours until you
* had spoken. Adapters derive this from auth/session.
*/
currentActorId(): string | null;
/** Absent => the UI hides reactions entirely. */
react?(threadId: string, messageId: string, emoji: string): Promise<void>;
/** Absent => the UI hides attachments. Storage/auth/limits are the host's concern. */
upload?(file: File): Promise<Attachment>;
/** Absent => treated as always connected (e.g. a pure-REST adapter). */
isConnected?(): boolean;
/** A thread's members (id + display name), for @mention autocomplete + highlighting.
* Absent => the composer offers no autocomplete (you can still type @text). */
listMembers?(threadId: string): Promise<Person[]>;
/** The people you can start a conversation with (org directory). Drives the "New message"
* people picker. Absent => the UI hides DM/group creation (you can still open channels). */
directory?(): Promise<Person[]>;
// ── Channels (optional capability) ──────────────────────────────
// A channel is just a third membership beyond dm/group: a discoverable, joinable room.
// Implement all four to enable the channels UI; absent => the UI hides channels entirely.
/** Discoverable channels in the caller's scope, each flagged `joined`. */
browseChannels?(): Promise<ChannelSummary[]>;
/** Create a channel; the creator joins as admin. Returns the new thread id. */
createChannel?(input: CreateChannelInput): Promise<{ threadId: string }>;
/** Join a (public) channel by id. */
joinChannel?(threadId: string): Promise<void>;
/** Leave a channel by id. */
leaveChannel?(threadId: string): Promise<void>;
}
@@ -0,0 +1,153 @@
// Runs the shared adapter conformance suite against KernelClientAdapter — proving it satisfies
// the same contract as the mock, over the REAL MessageSocket facade. Only the lowest socket.io
// layer is faked (via kernel-client's own SocketLike seam), so the facade's wire mapping is
// exercised for real. No live IIOS required.
import { describe, it, expect } from 'vitest';
import { MessageSocket } from '@insignia/iios-kernel-client';
import type { Message as KernelMessage, SocketLike } from '@insignia/iios-kernel-client';
import { runAdapterConformance } from '../conformance';
import { KernelClientAdapter, type RestPort } from './kernel-client';
const ME = 'me';
const SEEDED = 'th_seed';
/** An in-memory stand-in for the /message socket.io namespace: stores messages, echoes 'message'. */
function makeFakeSocket(): SocketLike {
const threads = new Map<string, KernelMessage[]>([
[
SEEDED,
[
{
id: 'seed_1',
threadId: SEEDED,
senderActorId: 'actor_other',
senderId: 'pp_other',
senderName: 'Other',
content: 'seeded message',
createdAt: new Date(0).toISOString(),
},
],
],
]);
const handlers = new Map<string, Set<(...a: unknown[]) => void>>();
let seq = 1;
const fire = (event: string, payload: unknown): void => handlers.get(event)?.forEach((h) => h(payload));
return {
on(event, handler) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)!.add(handler);
return undefined;
},
off(event, handler) {
handlers.get(event)?.delete(handler);
return undefined;
},
emit() {
return undefined; // typing/focus — no echo needed for conformance
},
async emitWithAck(event, payload) {
const p = (payload ?? {}) as {
threadId: string;
content?: string;
parentInteractionId?: string;
interactionId?: string;
type?: string;
value?: string;
};
if (event === 'open_thread') {
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
return { threadId: p.threadId, status: 'OPEN', history: [...threads.get(p.threadId)!] };
}
if (event === 'send_message') {
const msg: KernelMessage = {
id: `m_${seq++}`,
threadId: p.threadId,
senderActorId: `actor_${ME}`,
senderId: ME,
senderName: ME,
content: p.content ?? '',
createdAt: new Date().toISOString(),
...(p.parentInteractionId ? { parentInteractionId: p.parentInteractionId } : {}),
};
if (!threads.has(p.threadId)) threads.set(p.threadId, []);
threads.get(p.threadId)!.push(msg);
fire('message', msg);
return msg;
}
if (event === 'read') return { ok: true };
if (event === 'annotate') {
fire('annotation', {
threadId: p.threadId,
interactionId: p.interactionId,
type: p.type,
value: p.value,
op: 'add',
users: [ME],
userId: ME,
});
return {};
}
return {};
},
connect() {
return undefined;
},
disconnect() {
return undefined;
},
connected: true,
};
}
function makeFakeRest(): RestPort {
let seq = 1;
return {
async listThreads() {
return [];
},
async createThread() {
return { threadId: `th_new_${seq++}` };
},
async addParticipant() {
/* governed server-side; a fake always allows */
},
async discoverThreads() {
return [
{
threadId: 'th_pub',
subject: 'general',
metadata: { membership: 'channel', visibility: 'public', topic: 'Company-wide' },
participantCount: 3,
joined: false,
},
];
},
async leaveThread(threadId) {
return { threadId, participantCount: 0 };
},
};
}
function makeAdapter(): KernelClientAdapter {
const socket = new MessageSocket({ serviceUrl: 'http://iios.test', token: 'tok', autoConnect: false }, makeFakeSocket());
return new KernelClientAdapter({ currentUserId: ME, socket, rest: makeFakeRest() });
}
runAdapterConformance({ makeAdapter, seededThreadId: SEEDED, openWith: ['pp_a'] });
describe('KernelClientAdapter channels', () => {
it('browse maps discovered public channels; create/join/leave delegate to the transport', async () => {
const adapter = makeAdapter();
const list = await adapter.browseChannels!();
expect(list[0]).toMatchObject({ threadId: 'th_pub', name: 'general', visibility: 'public', joined: false, memberCount: 3, topic: 'Company-wide' });
const { threadId } = await adapter.createChannel!({ name: 'design', topic: 'UI', visibility: 'public' });
expect(typeof threadId).toBe('string');
await expect(adapter.joinChannel!('th_pub')).resolves.toBeUndefined();
await expect(adapter.leaveChannel!('th_pub')).resolves.toBeUndefined();
});
});
@@ -0,0 +1,303 @@
// Transport adapter: implements MessagingAdapter over @insignia/iios-kernel-client
// (browser → IIOS directly, token-in). This is the "plug into any app with a token"
// path — the same transport chat-web and support-sdk use.
//
// It lives in adapters/ (the ONLY layer allowed to import a transport) and ships from
// its own subpath, so core UI never pulls socket code it can't use.
import { MessageSocket, RestClient } from '@insignia/iios-kernel-client';
import type {
AnnotationEvent,
DiscoveredThread,
Message as KernelMessage,
MessageEvents,
OpenThreadResult,
ReceiptEvent,
ThreadSummary,
TypingEvent,
} from '@insignia/iios-kernel-client';
import type { MessagingAdapter } from '../adapter';
import type {
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Reaction,
SendOpts,
Unsubscribe,
} from '../types';
/** The slice of MessageSocket the adapter needs — the real facade satisfies it; tests inject a fake. */
export interface SocketPort {
openThread(threadId?: string, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult>;
sendMessage(threadId: string, content: string, opts?: { parentInteractionId?: string; mentions?: string[] }): Promise<KernelMessage>;
react(threadId: string, interactionId: string, value: string): Promise<void>;
markRead(threadId: string, interactionId: string): Promise<{ ok: boolean }>;
typing(threadId: string): void;
on<E extends keyof MessageEvents>(event: E, handler: MessageEvents[E]): () => void;
}
/** The slice of RestClient the adapter needs. */
export interface RestPort {
listThreads(filter?: { metadata?: Record<string, string> }): Promise<ThreadSummary[]>;
createThread(opts: { membership?: string; creatorRole?: string; subject?: string; metadata?: Record<string, unknown> }): Promise<{ threadId: string }>;
addParticipant(threadId: string, userId: string): Promise<void>;
discoverThreads(filter?: { metadata?: Record<string, string> }): Promise<DiscoveredThread[]>;
leaveThread(threadId: string): Promise<{ threadId: string; participantCount: number }>;
}
export interface KernelClientAdapterConfig {
/**
* The current user's id in IIOS's `senderId` space (email/username), from the host's auth —
* NEVER inferred from message history. This is exactly `currentActorId()`, and it's the bug the
* conformance suite kills: identity comes from the session, not from a message you happened to send.
*/
currentUserId: string;
socket: SocketPort;
rest: RestPort;
/** Optional opaque metadata filter for the conversation list (e.g. { source: 'crm-messenger' }). */
threadFilter?: Record<string, string>;
}
const REACTION = 'reaction';
export class KernelClientAdapter implements MessagingAdapter {
private readonly me: string;
private readonly socket: SocketPort;
private readonly rest: RestPort;
private readonly threadFilter?: Record<string, string>;
/** Per-thread UI subscribers. The socket fans server events in; these fan them out. */
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
/** Reaction users per message (messageId → emoji → userSet), so an annotation delta becomes a full set. */
private readonly reactions = new Map<string, Map<string, Set<string>>>();
private readonly joined = new Set<string>();
private readonly offs: Array<() => void> = [];
constructor(cfg: KernelClientAdapterConfig) {
this.me = cfg.currentUserId;
this.socket = cfg.socket;
this.rest = cfg.rest;
this.threadFilter = cfg.threadFilter;
this.offs.push(
this.socket.on('message', (m: KernelMessage) => {
this.ingestReactions(m);
this.emit(m.threadId, { kind: 'message', message: this.toMessage(m) });
}),
);
this.offs.push(
this.socket.on('typing', (e: TypingEvent) => this.emit(e.threadId, { kind: 'typing', userId: e.userId })),
);
this.offs.push(
// Receipts carry no threadId, so fan to every open thread; the UI filters by messageId.
this.socket.on('receipt', (e: ReceiptEvent) => this.broadcast({ kind: 'receipt', messageId: e.interactionId, actorId: e.actorId })),
);
this.offs.push(
this.socket.on('annotation', (e: AnnotationEvent) => {
if (e.type !== REACTION) return;
this.setReactionUsers(e.interactionId, e.value, e.users);
this.emit(e.threadId, { kind: 'reaction', messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
}),
);
}
currentActorId(): string {
return this.me;
}
async listConversations(): Promise<Conversation[]> {
const threads = await this.rest.listThreads(this.threadFilter ? { metadata: this.threadFilter } : undefined);
return threads.map((t) => this.toConversation(t));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const membership: Membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const { threadId } = await this.rest.createThread({
membership,
creatorRole: membership === 'group' ? 'ADMIN' : 'MEMBER',
...(p.subject ? { subject: p.subject } : {}),
});
// Governance (DM cap, roles) is enforced server-side by IIOS/OPA; a rejected add surfaces up there.
for (const id of p.participantIds) await this.rest.addParticipant(threadId, id).catch(() => undefined);
return { threadId };
}
async history(threadId: string): Promise<Message[]> {
const res = await this.socket.openThread(threadId); // joins the thread, so live events start flowing
this.joined.add(threadId);
return res.history.map((m) => {
this.ingestReactions(m);
return this.toMessage(m);
});
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
// Attachments are intentionally not forwarded here: kernel-client exposes no media/presign yet,
// and the SDK Attachment carries a display `url`, not a storage `contentRef`. Media is a follow-up
// (kernel-client media methods + a contentRef on Attachment). Text + reply threading work today.
const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
};
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
return this.toMessage(m);
}
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
await this.socket.react(threadId, messageId, emoji);
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
if (!this.joined.has(threadId)) {
this.joined.add(threadId);
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
}
return () => {
this.listeners.get(threadId)?.delete(cb);
};
}
sendTyping(threadId: string): void {
this.socket.typing(threadId);
}
async markRead(threadId: string, messageId: string): Promise<void> {
await this.socket.markRead(threadId, messageId);
}
// ── Channels ────────────────────────────────────────────────────
async browseChannels(): Promise<ChannelSummary[]> {
const found = await this.rest.discoverThreads({ metadata: { membership: 'channel', visibility: 'public' } });
return found.map((d) => {
const bag = (d.metadata as { topic?: string; visibility?: string } | null) ?? {};
const visibility: ChannelVisibility = bag.visibility === 'private' ? 'private' : 'public';
return {
threadId: d.threadId,
name: d.subject ?? 'channel',
topic: bag.topic ?? null,
visibility,
memberCount: d.participantCount,
joined: d.joined,
};
});
}
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
return this.rest.createThread({
membership: 'channel',
creatorRole: 'ADMIN',
subject: input.name,
metadata: { visibility: input.visibility, ...(input.topic ? { topic: input.topic } : {}) },
});
}
async joinChannel(threadId: string): Promise<void> {
// Self-join is a governed open_thread; OPA allows it for a public channel.
await this.socket.openThread(threadId);
this.joined.add(threadId);
}
async leaveChannel(threadId: string): Promise<void> {
await this.rest.leaveThread(threadId);
this.joined.delete(threadId);
}
/** Detach socket handlers. Not part of the contract — call on teardown to avoid leaks. */
close(): void {
for (const off of this.offs) off();
this.offs.length = 0;
this.listeners.clear();
}
// ── mapping ────────────────────────────────────────────────────
private toMessage(m: KernelMessage): Message {
return {
id: m.id,
// `senderId` (email/username), NOT the actor id — it matches currentActorId() and is the
// reliable "is this mine?" field. Never inferred from history.
actorId: m.senderId ?? null,
text: m.content ?? '',
at: m.createdAt,
parentInteractionId: m.parentInteractionId ?? null,
reactions: this.reactionsOf(m.id),
};
}
private toConversation(t: ThreadSummary): Conversation {
const others = t.participants.filter((p) => p !== this.me);
const membership: Membership | null =
t.membership === 'dm' || t.membership === 'group' || t.membership === 'channel' ? t.membership : null;
const topic = (t.metadata as { topic?: string } | null)?.topic;
return {
threadId: t.threadId,
title: t.subject?.trim() || others.join(', ') || 'Conversation',
subject: t.subject,
membership,
participants: [...t.participants],
unread: t.unread,
...(topic != null ? { topic } : {}),
...(t.lastMessage ? { lastMessage: t.lastMessage } : {}),
...(t.lastAt ? { lastAt: t.lastAt } : {}),
};
}
// ── reaction state ─────────────────────────────────────────────
private ingestReactions(m: KernelMessage): void {
for (const a of m.annotations ?? []) {
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
}
}
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
let byEmoji = this.reactions.get(messageId);
if (!byEmoji) {
byEmoji = new Map();
this.reactions.set(messageId, byEmoji);
}
if (users.length === 0) byEmoji.delete(emoji);
else byEmoji.set(emoji, new Set(users));
}
private reactionsOf(messageId: string): Reaction[] {
const byEmoji = this.reactions.get(messageId);
if (!byEmoji) return [];
const out: Reaction[] = [];
for (const [emoji, users] of byEmoji) {
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
}
return out;
}
// ── event fan-out ──────────────────────────────────────────────
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
private broadcast(e: MessageEvent): void {
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
}
}
/** Convenience: build an adapter that talks straight to IIOS with a token. */
export function connectKernelAdapter(opts: {
serviceUrl: string;
token: string;
currentUserId: string;
threadFilter?: Record<string, string>;
autoConnect?: boolean;
}): KernelClientAdapter {
const rest = new RestClient({ serviceUrl: opts.serviceUrl, token: opts.token });
const socket = new MessageSocket({ serviceUrl: opts.serviceUrl, token: opts.token, autoConnect: opts.autoConnect ?? true });
return new KernelClientAdapter({
currentUserId: opts.currentUserId,
socket,
rest,
...(opts.threadFilter ? { threadFilter: opts.threadFilter } : {}),
});
}
@@ -0,0 +1,109 @@
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 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;
}
}
@@ -0,0 +1,252 @@
import type { MessagingAdapter } from '../adapter';
import type {
Attachment,
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Person,
SendOpts,
Unsubscribe,
} from '../types';
const ME = 'me';
export const MOCK_PEOPLE: Person[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
{ id: 'pp_priya', name: 'Priya Nair', kind: 'staff' },
{ id: 'cust_acme', name: 'Acme Roofing (Client)', kind: 'customer' },
{ id: 'cust_globex', name: 'Globex Homes (Client)', kind: 'customer' },
];
interface MockThread {
threadId: string;
membership: Membership;
subject: string | null;
participants: string[];
messages: Message[];
topic?: string | null;
visibility?: ChannelVisibility;
}
const nameById = new Map(MOCK_PEOPLE.map((p) => [p.id, p.name]));
/**
* In-memory adapter for demos and tests. State is PER INSTANCE — the CRM's version
* used a module-level Map, which leaks between tests. Each `new MockAdapter()` is
* fully isolated.
*/
export class MockAdapter implements MessagingAdapter {
private seq = 100;
private threads = new Map<string, MockThread>();
private listeners = new Map<string, Set<(e: MessageEvent) => void>>();
constructor(private readonly now: () => string = () => new Date().toISOString()) {
const seededAt = this.now();
this.threads.set('th_mock_1', {
threadId: 'th_mock_1',
membership: 'dm',
subject: null,
participants: [ME, 'pp_sofia'],
messages: [
{
id: 'm1',
actorId: 'pp_sofia',
text: 'Can you review the Henderson estimate?',
at: seededAt,
reactions: [],
},
],
});
this.threads.set('th_mock_2', {
threadId: 'th_mock_2',
membership: 'group',
subject: 'Storm response — East side',
participants: [ME, 'pp_dan', 'pp_priya'],
messages: [
{ id: 'm2', actorId: 'pp_dan', text: 'Crew is rolling out at 7.', at: seededAt, reactions: [] },
],
});
// Channels: a joined public one, a joinable public one (I'm NOT in it → shows in browse only),
// and a private one I'm a member of.
this.threads.set('th_ch_general', {
threadId: 'th_ch_general', membership: 'channel', subject: 'general', topic: 'Company-wide chatter',
visibility: 'public', participants: [ME, 'pp_dan', 'pp_priya', 'pp_sofia'],
messages: [{ id: 'c1', actorId: 'pp_priya', text: 'Welcome to #general 👋', at: seededAt, reactions: [] }],
});
this.threads.set('th_ch_random', {
threadId: 'th_ch_random', membership: 'channel', subject: 'random', topic: 'Non-work banter',
visibility: 'public', participants: ['pp_dan', 'pp_sofia'], messages: [],
});
this.threads.set('th_ch_deals', {
threadId: 'th_ch_deals', membership: 'channel', subject: 'deals', topic: 'Big pipeline moves',
visibility: 'private', participants: [ME, 'pp_sofia'], messages: [],
});
}
currentActorId(): string {
return ME;
}
async listConversations(): Promise<Conversation[]> {
// Only threads I'm a member of — an un-joined public channel appears in browse, not here.
return [...this.threads.values()]
.filter((t) => t.participants.includes(ME))
.map((t) => {
const last = t.messages[t.messages.length - 1];
const others = t.participants.filter((p) => p !== ME);
return {
threadId: t.threadId,
title: t.subject || others.map((id) => nameById.get(id) ?? id).join(', ') || 'Conversation',
subject: t.subject,
membership: t.membership,
participants: [...t.participants],
unread: 0,
...(t.topic != null ? { topic: t.topic } : {}),
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
};
});
}
async browseChannels(): Promise<ChannelSummary[]> {
// Public channels are discoverable; private ones only if I'm already a member.
return [...this.threads.values()]
.filter((t) => t.membership === 'channel' && (t.visibility === 'public' || t.participants.includes(ME)))
.map((t) => ({
threadId: t.threadId,
name: t.subject ?? 'channel',
topic: t.topic ?? null,
visibility: t.visibility ?? 'public',
memberCount: t.participants.length,
joined: t.participants.includes(ME),
}));
}
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
const threadId = `th_ch_${this.seq++}`;
this.threads.set(threadId, {
threadId,
membership: 'channel',
subject: input.name,
topic: input.topic ?? null,
visibility: input.visibility,
participants: [ME],
messages: [],
});
return { threadId };
}
async joinChannel(threadId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t && !t.participants.includes(ME)) t.participants = [...t.participants, ME];
}
async leaveChannel(threadId: string): Promise<void> {
const t = this.threads.get(threadId);
if (t) t.participants = t.participants.filter((p) => p !== ME);
}
async listMembers(threadId: string): Promise<Person[]> {
const t = this.threads.get(threadId);
if (!t) return [];
return t.participants.map((id) => {
if (id === ME) return { id: ME, name: 'You', kind: 'staff' };
return MOCK_PEOPLE.find((p) => p.id === id) ?? { id, name: id, kind: 'staff' };
});
}
async directory(): Promise<Person[]> {
return MOCK_PEOPLE.map((p) => ({ ...p }));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
// A DM to someone you already have reuses the existing 1:1 thread (dedupe, like the live door).
if ((p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group')) === 'dm' && p.participantIds.length === 1) {
const target = p.participantIds[0];
for (const [id, t] of this.threads) {
if (t.membership === 'dm' && t.participants.length === 2 && t.participants.includes(ME) && t.participants.includes(target)) {
return { threadId: id };
}
}
}
const membership = p.membership ?? (p.participantIds.length === 1 ? 'dm' : 'group');
const threadId = `th_mock_${this.seq++}`;
this.threads.set(threadId, {
threadId,
membership,
subject: p.subject ?? null,
participants: [ME, ...p.participantIds],
messages: [],
});
return { threadId };
}
async history(threadId: string): Promise<Message[]> {
return [...(this.threads.get(threadId)?.messages ?? [])];
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
const t = this.threads.get(threadId);
if (!t) throw new Error(`Unknown thread: ${threadId}`);
const message: Message = {
id: `m_${this.seq++}`,
actorId: ME,
text: content,
at: this.now(),
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
t.messages = [...t.messages, message];
this.emit(threadId, { kind: 'message', message });
return message;
}
async react(threadId: string, messageId: string, emoji: string): Promise<void> {
const t = this.threads.get(threadId);
if (!t) return;
let next: Message | undefined;
t.messages = t.messages.map((m) => {
if (m.id !== messageId) return m;
const existing = (m.reactions ?? []).find((r) => r.emoji === emoji);
const reactions = existing
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
next = { ...m, reactions };
return next;
});
if (next) this.emit(threadId, { kind: 'reaction', messageId, reactions: next.reactions ?? [] });
}
async upload(file: File): Promise<Attachment> {
return { url: `mock://uploads/${file.name}`, mime: file.type, name: file.name };
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
return () => {
this.listeners.get(threadId)?.delete(cb);
};
}
sendTyping(): void {
// No-op: nobody is typing back in a mock.
}
async markRead(): Promise<void> {
// No-op: the mock has no second party to report a read.
}
isConnected(): boolean {
return true;
}
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
}
@@ -0,0 +1,41 @@
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
// This package is ESM ("type": "module") — __dirname does not exist here.
const SRC = fileURLToPath(new URL('.', import.meta.url));
const ADAPTERS = join(SRC, 'adapters');
function tsFilesIn(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) out.push(...tsFilesIn(full));
else if (/\.tsx?$/.test(full)) out.push(full);
}
return out;
}
// The whole premise of this package: core renders, adapters transport. If core ever
// imports a transport, an app on a different backend pays for socket code it cannot
// use — which is exactly how iios-message-web welded itself to MessageSocket.
describe('transport boundary', () => {
const coreFiles = tsFilesIn(SRC).filter((f) => !f.startsWith(ADAPTERS) && !/\.test\.tsx?$/.test(f));
it('has core files to check', () => {
expect(coreFiles.length).toBeGreaterThan(0);
});
it('core never imports a transport package', () => {
const offenders: string[] = [];
for (const file of coreFiles) {
const content = readFileSync(file, 'utf8');
if (/@insignia\/iios-kernel-client|socket\.io|@abe-kap\/appshell-sdk/.test(content)) {
offenders.push(file.replace(SRC, 'src'));
}
}
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,93 @@
import { useState, type FormEvent } from 'react';
import { useChannels } from '../hooks/use-channels';
import type { ChannelVisibility } from '../types';
/** Browse + join discoverable channels, and create a new one. Shown in the main pane. */
export function ChannelBrowser({ onJoined }: { onJoined?: (threadId: string) => void }) {
const { browsable, loading, error, join, create } = useChannels();
const [name, setName] = useState('');
const [topic, setTopic] = useState('');
const [visibility, setVisibility] = useState<ChannelVisibility>('public');
const [busy, setBusy] = useState(false);
async function submitCreate(e: FormEvent): Promise<void> {
e.preventDefault();
const n = name.trim();
if (!n || busy) return;
setBusy(true);
try {
const threadId = await create({ name: n, ...(topic.trim() ? { topic: topic.trim() } : {}), visibility });
setName('');
setTopic('');
onJoined?.(threadId);
} catch {
// surfaced via the hook's error
} finally {
setBusy(false);
}
}
async function doJoin(threadId: string): Promise<void> {
await join(threadId);
onJoined?.(threadId);
}
return (
<div className="miu-browser">
<div className="miu-browser-head">Channels</div>
<form className="miu-channel-create" onSubmit={submitCreate}>
<input
className="miu-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New channel name"
aria-label="Channel name"
/>
<input
className="miu-input"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="Topic (optional)"
aria-label="Channel topic"
/>
<div className="miu-channel-vis">
<label>
<input type="radio" name="miu-vis" checked={visibility === 'public'} onChange={() => setVisibility('public')} /> Public
</label>
<label>
<input type="radio" name="miu-vis" checked={visibility === 'private'} onChange={() => setVisibility('private')} /> Private
</label>
</div>
<button type="submit" className="miu-send" disabled={!name.trim() || busy}>
Create
</button>
</form>
<div className="miu-browser-list">
{loading && browsable.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{!loading && browsable.length === 0 ? <div className="miu-empty">No channels yet create one above.</div> : null}
{browsable.map((c) => (
<div key={c.threadId} className="miu-browser-row">
<span className="miu-channel-glyph" aria-hidden="true">{c.visibility === 'private' ? '🔒' : '#'}</span>
<span className="miu-browser-main">
<span className="miu-browser-name">{c.name}</span>
{c.topic ? <span className="miu-browser-topic">{c.topic}</span> : null}
<span className="miu-browser-meta">
{c.memberCount} member{c.memberCount === 1 ? '' : 's'}
</span>
</span>
{c.joined ? (
<span className="miu-browser-joined">Joined</span>
) : (
<button type="button" className="miu-join" onClick={() => void doJoin(c.threadId)}>
Join
</button>
)}
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('channels (mock adapter)', () => {
it('browse returns public channels + private ones I am in, with a joined flag', async () => {
const a = new MockAdapter();
const list = await a.browseChannels();
const byId = new Map(list.map((c) => [c.threadId, c]));
expect(byId.get('th_ch_general')?.joined).toBe(true); // public, I'm in
expect(byId.get('th_ch_random')?.joined).toBe(false); // public, I'm NOT in
expect(byId.get('th_ch_deals')?.joined).toBe(true); // private, I'm in
// A private channel I'm not in must never surface in browse — none seeded, so all private here are mine.
expect(list.every((c) => c.visibility === 'public' || c.joined)).toBe(true);
});
it('join adds me and the channel then appears in my conversation list', async () => {
const a = new MockAdapter();
expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(false);
await a.joinChannel('th_ch_random');
expect((await a.listConversations()).some((c) => c.threadId === 'th_ch_random')).toBe(true);
expect((await a.browseChannels()).find((c) => c.threadId === 'th_ch_random')?.joined).toBe(true);
});
it('create makes a channel I am a member of', async () => {
const a = new MockAdapter();
const { threadId } = await a.createChannel({ name: 'design', topic: 'UI stuff', visibility: 'public' });
const conv = (await a.listConversations()).find((c) => c.threadId === threadId);
expect(conv?.membership).toBe('channel');
expect(conv?.topic).toBe('UI stuff');
});
it('UI: browse, then join a channel — it moves into the Channels section', async () => {
mount();
// Open the browser via the Channels section "+".
fireEvent.click(await screen.findByTitle('Browse channels'));
// #random is browse-only (not joined) → has a Join button.
expect(await screen.findByText('random')).toBeTruthy();
const joinButtons = screen.getAllByText('Join');
fireEvent.click(joinButtons[0]!);
// After joining, we jump to the thread and the browser closes → composer is shown.
await waitFor(() => expect(screen.getByLabelText('Message')).toBeTruthy());
});
});
@@ -0,0 +1,147 @@
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
import {
SPECIAL_MENTIONS,
insertMention,
resolveMentions,
trailingMentionQuery,
} from '../mentions';
import type { Attachment, Person, SendOpts } from '../types';
interface Suggestion {
key: string;
label: string;
insert: string;
}
/**
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
*/
export function Composer({
members,
canUpload,
upload,
onSend,
onTyping,
parentInteractionId,
placeholder = 'Type a message… @ to mention',
}: {
members: Person[];
canUpload: boolean;
upload: (file: File) => Promise<Attachment>;
onSend: (text: string, opts?: SendOpts) => Promise<void>;
onTyping?: () => void;
parentInteractionId?: string;
placeholder?: string;
}) {
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<Attachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const query = trailingMentionQuery(draft);
const suggestions = useMemo<Suggestion[]>(() => {
if (query === null) return [];
const q = query.toLowerCase();
const specials = SPECIAL_MENTIONS.filter((s) => s.startsWith(q)).map((s) => ({ key: `@${s}`, label: `@${s}`, insert: s }));
const people = members.filter((m) => m.name.toLowerCase().includes(q)).map((m) => ({ key: m.id, label: m.name, insert: m.name }));
return [...specials, ...people].slice(0, 6);
}, [query, members]);
const showSuggest = query !== null && suggestions.length > 0;
function pick(insert: string): void {
setDraft((d) => insertMention(d, insert));
}
async function onPickFile(e: ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploading(true);
try {
setStaged(await upload(file));
} catch {
/* host surfaces upload errors */
} finally {
setUploading(false);
}
}
async function submit(e?: FormEvent): Promise<void> {
e?.preventDefault();
const text = draft.trim();
if ((!text && !staged) || sending) return;
const mentions = resolveMentions(text, members);
const att = staged;
setDraft('');
setStaged(null);
setSending(true);
try {
await onSend(text, {
...(mentions.length ? { mentions } : {}),
...(att ? { attachment: att } : {}),
...(parentInteractionId ? { parentInteractionId } : {}),
});
} catch {
setStaged(att);
} finally {
setSending(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
if (showSuggest && e.key === 'Enter') {
e.preventDefault();
pick(suggestions[0]!.insert);
}
}
return (
<form className="miu-composer" onSubmit={submit}>
{showSuggest ? (
<ul className="miu-suggest" role="listbox" aria-label="Mention suggestions">
{suggestions.map((s) => (
<li key={s.key}>
<button type="button" role="option" aria-selected="false" className="miu-suggest-item" onClick={() => pick(s.insert)}>
{s.label}
</button>
</li>
))}
</ul>
) : null}
{staged ? (
<div className="miu-staged">
📎 {staged.name}
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
</button>
</div>
) : null}
<div className="miu-composer-row">
{canUpload ? (
<>
<input ref={fileRef} type="file" className="miu-file-input" onChange={onPickFile} aria-label="Attach a file" />
<button type="button" className="miu-attach-btn" title="Attach a file" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? '…' : '📎'}
</button>
</>
) : null}
<input
className="miu-input"
value={draft}
placeholder={placeholder}
aria-label="Message"
onChange={(e) => {
setDraft(e.target.value);
onTyping?.();
}}
onKeyDown={onKeyDown}
/>
<button type="submit" className="miu-send" disabled={(!draft.trim() && !staged) || sending}>
Send
</button>
</div>
</form>
);
}
@@ -0,0 +1,52 @@
import type { Conversation } from '../types';
/** Initials for the avatar chip — first letters of the first two words. */
function initials(title: string): string {
const parts = title.trim().split(/\s+/).filter(Boolean);
const chars = (parts[0]?.[0] ?? '') + (parts[1]?.[0] ?? '');
return (chars || '?').toUpperCase();
}
/**
* Presentational conversation list. Owns no data fetching — the parent passes the
* conversations (from useConversations) so a host can also drive it from its own store.
*/
export function ConversationList({
conversations,
selectedId,
onSelect,
}: {
conversations: Conversation[];
selectedId?: string | null;
onSelect: (threadId: string) => void;
}) {
if (conversations.length === 0) {
return <div className="miu-empty">No conversations yet.</div>;
}
return (
<ul className="miu-convlist" role="list">
{conversations.map((c) => (
<li key={c.threadId}>
<button
type="button"
className={`miu-convrow${c.threadId === selectedId ? ' is-active' : ''}`}
onClick={() => onSelect(c.threadId)}
>
<span className="miu-avatar" aria-hidden="true">
{c.membership === 'channel' ? '#' : initials(c.title)}
</span>
<span className="miu-convrow-main">
<span className="miu-convrow-title">{c.title}</span>
{c.lastMessage ? <span className="miu-convrow-preview">{c.lastMessage}</span> : null}
</span>
{c.unread > 0 ? (
<span className="miu-badge" aria-label={`${c.unread} unread`}>
{c.unread}
</span>
) : null}
</button>
</li>
))}
</ul>
);
}
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { highlightMentions } from '../mentions';
import type { Attachment } from '../types';
import type { UiMessage } from '../hooks/use-messages';
const REACTION_EMOJIS = ['👍', '❤️', '😂', '🎉', '👀'];
const isImage = (mime: string): boolean => mime.startsWith('image/');
function AttachmentView({ att }: { att: Attachment }) {
if (isImage(att.mime)) {
return (
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-img-link">
<img src={att.url} alt={att.name} className="miu-att-img" />
</a>
);
}
return (
<a href={att.url} target="_blank" rel="noreferrer" className="miu-att-file">
📎 {att.name}
</a>
);
}
/** One rendered message: bubble (with @mention highlighting), attachment, reactions, seen tick,
* and — in the main thread only — a thread/reply affordance. */
export function MessageItem({
message,
memberNames,
canReact,
onReact,
seen,
replyCount,
onOpenThread,
}: {
message: UiMessage;
memberNames: string[];
canReact: boolean;
onReact: (messageId: string, emoji: string) => void;
seen: boolean;
/** Present only in the main thread (not inside the pane). undefined => no thread affordance. */
replyCount?: number;
onOpenThread?: (messageId: string) => void;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const m = message;
return (
<div className={`miu-msg${message.mine ? ' is-mine' : ''}${message.pending ? ' is-pending' : ''}`}>
<div className="miu-bubble-row">
<div className="miu-bubble">
{m.text ? highlightMentions(m.text, memberNames) : null}
{m.attachment ? <AttachmentView att={m.attachment} /> : null}
</div>
<div className="miu-msg-actions">
{canReact ? (
<div className="miu-react-wrap">
<button type="button" className="miu-react-btn" title="React" onClick={() => setPickerOpen((p) => !p)}>
🙂
</button>
{pickerOpen ? (
<div className="miu-react-picker">
{REACTION_EMOJIS.map((e) => (
<button
key={e}
type="button"
className="miu-react-emoji"
onClick={() => {
onReact(m.id, e);
setPickerOpen(false);
}}
>
{e}
</button>
))}
</div>
) : null}
</div>
) : null}
{onOpenThread ? (
<button type="button" className="miu-react-btn" title="Reply in thread" onClick={() => onOpenThread(m.id)}>
💬
</button>
) : null}
</div>
</div>
{m.reactions && m.reactions.length > 0 ? (
<div className="miu-reactions">
{m.reactions.map((r) => (
<button key={r.emoji} type="button" className={`miu-reaction${r.mine ? ' is-mine' : ''}`} onClick={() => canReact && onReact(m.id, r.emoji)}>
{r.emoji} {r.count}
</button>
))}
</div>
) : null}
{replyCount !== undefined && replyCount > 0 && onOpenThread ? (
<button type="button" className="miu-thread-link" onClick={() => onOpenThread(m.id)}>
💬 {replyCount} {replyCount === 1 ? 'reply' : 'replies'}
</button>
) : null}
{message.mine && seen ? <span className="miu-seen">Seen</span> : null}
</div>
);
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('<Messenger /> (rendered UI over an adapter)', () => {
it('renders the conversation list and auto-selects the first thread', async () => {
mount();
// Seeded group conversation title from the mock.
expect(await screen.findByText('Storm response — East side')).toBeTruthy();
// Auto-selected thread shows its seeded message.
expect(await screen.findByText('Crew is rolling out at 7.')).toBeTruthy();
});
it('sends a message through the adapter and shows it in the thread', async () => {
mount();
// Wait for the auto-selected thread's composer (avoids a race with the auto-select effect).
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'on our way' } });
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(screen.getByText('on our way')).toBeTruthy());
// Composer cleared after send.
expect(input.value).toBe('');
});
it('switches threads when another conversation is clicked', async () => {
mount();
// Click the DM (its title is the other participant's name from the mock directory).
fireEvent.click(await screen.findByText('Sofia Ramirez'));
expect(await screen.findByText('Can you review the Henderson estimate?')).toBeTruthy();
});
});
@@ -0,0 +1,116 @@
import { useEffect, useMemo, useState } from 'react';
import { useConversations } from '../hooks/use-conversations';
import { useAdapter } from '../provider';
import { ConversationList } from './conversation-list';
import { ChannelBrowser } from './channel-browser';
import { NewConversation } from './new-conversation';
import { Thread } from './thread';
import { ThreadPane } from './thread-pane';
/**
* The drop-in messenger: sectioned conversation list (Channels / Direct messages) + open thread,
* with a channel browser when the adapter supports channels. Owns only selection + browse state;
* all data flows through the injected adapter via the hooks.
*/
export function Messenger() {
const { conversations, loading, error, refetch } = useConversations();
const adapter = useAdapter();
const channelsSupported = typeof adapter.browseChannels === 'function';
const directorySupported = typeof adapter.directory === 'function';
const [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
const [composing, setComposing] = useState(false); // "New message" people picker open
const [activeRoot, setActiveRoot] = useState<string | null>(null); // open thread pane's root message
useEffect(() => {
if (browsing || composing) return;
if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected, browsing, composing]);
// Switching conversations (or into browse/compose) closes any open thread pane.
useEffect(() => setActiveRoot(null), [selected, browsing, composing]);
const channels = useMemo(() => conversations.filter((c) => c.membership === 'channel'), [conversations]);
const dms = useMemo(() => conversations.filter((c) => c.membership !== 'channel'), [conversations]);
function pick(threadId: string): void {
setBrowsing(false);
setComposing(false);
setSelected(threadId);
}
function openBrowse(): void {
setComposing(false);
setBrowsing(true);
}
function openCompose(): void {
setBrowsing(false);
setComposing(true);
}
return (
<div className="miu-messenger">
<aside className="miu-sidebar">
{loading && conversations.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{channelsSupported ? (
<div className="miu-section">
<div className="miu-section-head">
<span>Channels</span>
<button type="button" className="miu-section-add" title="Browse channels" onClick={openBrowse}>
</button>
</div>
{channels.length > 0 ? (
<ConversationList conversations={channels} selectedId={browsing || composing ? null : selected} onSelect={pick} />
) : (
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
)}
</div>
) : null}
<div className="miu-section">
{channelsSupported || directorySupported ? (
<div className="miu-section-head">
<span>Direct messages</span>
{directorySupported ? (
<button type="button" className="miu-section-add" title="New message" onClick={openCompose}>
</button>
) : null}
</div>
) : null}
<ConversationList conversations={dms} selectedId={browsing || composing ? null : selected} onSelect={pick} />
</div>
</aside>
<section className="miu-main">
{browsing ? (
<ChannelBrowser
onJoined={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : composing ? (
<NewConversation
onCreated={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : (
<Thread threadId={selected} activeRootId={activeRoot} onOpenThread={setActiveRoot} />
)}
</section>
{!browsing && !composing && selected && activeRoot ? (
<ThreadPane threadId={selected} rootId={activeRoot} onClose={() => setActiveRoot(null)} />
) : null}
</div>
);
}
@@ -0,0 +1,35 @@
import { useState, type ReactNode, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
// The SDK theme tokens. A body-portaled node is outside the `.miu-messenger`/`.miu-inbox`
// subtree that defines these, so we copy their resolved values onto the portal root.
const THEME_VARS = [
'--miu-bg', '--miu-panel', '--miu-panel-2', '--miu-border',
'--miu-text', '--miu-muted', '--miu-accent', '--miu-accent-text', '--miu-radius',
] as const;
function copyThemeVars(): Record<string, string> {
if (typeof document === 'undefined') return {};
const src = document.querySelector('.miu-messenger, .miu-inbox');
if (!src) return {};
const cs = getComputedStyle(src);
const out: Record<string, string> = {};
for (const v of THEME_VARS) {
const val = cs.getPropertyValue(v).trim();
if (val) out[v] = val;
}
return out;
}
/**
* Render an overlay into document.body so it escapes the host's stacking context and overflow.
* A host wrapper like `.view { position: relative; z-index: 1 }` traps a `position: fixed` overlay
* BELOW a sibling sticky header no matter how high its z-index — the only robust fix is to leave
* that stacking context entirely. Theme tokens are copied from the live surface (captured once on
* mount, synchronously, so there is no unstyled first paint) and re-applied on the portal root.
*/
export function ModalPortal({ children }: { children: ReactNode }) {
const [vars] = useState<Record<string, string>>(copyThemeVars);
if (typeof document === 'undefined') return null;
return createPortal(<div className="miu-portal" style={vars as CSSProperties}>{children}</div>, document.body);
}
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { Messenger } from './messenger';
function mount(adapter = new MockAdapter()) {
render(
<MessagingProvider adapter={adapter}>
<Messenger />
</MessagingProvider>,
);
return adapter;
}
describe('MockAdapter directory + openThread', () => {
it('directory lists people you can message', async () => {
const a = new MockAdapter();
const people = await a.directory();
expect(people.some((p) => p.name === 'Dan Whitaker')).toBe(true);
});
it('one participant opens a dm; two+ open a group with a subject', async () => {
const a = new MockAdapter();
const dm = await a.openThread({ participantIds: ['pp_dan'], membership: 'dm' });
const list = await a.listConversations();
expect(list.find((c) => c.threadId === dm.threadId)?.membership).toBe('dm');
const group = await a.openThread({ participantIds: ['pp_dan', 'pp_priya'], membership: 'group', subject: 'Roof crew' });
const g = (await a.listConversations()).find((c) => c.threadId === group.threadId);
expect(g?.membership).toBe('group');
expect(g?.title).toBe('Roof crew');
});
it('opening a dm with the same person reuses the existing thread', async () => {
const a = new MockAdapter();
const first = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
const second = await a.openThread({ participantIds: ['pp_priya'], membership: 'dm' });
expect(second.threadId).toBe(first.threadId);
});
});
describe('<Messenger /> new conversation flow', () => {
it('opens the picker from the Direct messages +, and starting a chat leaves the picker', async () => {
mount();
// Open the "New message" picker.
fireEvent.click(await screen.findByTitle('New message'));
expect(await screen.findByText('New message')).toBeTruthy();
// Pick a person and start a DM.
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(screen.getByText('Start chat'));
// The picker closes (we're back in a thread view — the picker heading is gone).
await waitFor(() => expect(screen.queryByText('Start chat')).toBeNull());
});
it('selecting two people switches the action to group create', async () => {
mount();
fireEvent.click(await screen.findByTitle('New message'));
fireEvent.click(await screen.findByText('Dan Whitaker'));
fireEvent.click(await screen.findByText('Priya Nair'));
expect(screen.getByText(/Create group \(2\)/)).toBeTruthy();
expect(screen.getByLabelText('Group name')).toBeTruthy();
});
});
@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Person } from '../types';
/**
* Start a direct message or a group — Slack-style. Pick people from the org directory: one selected
* opens a DM (deduped by the adapter), two or more create a group with an optional name. Shown in
* the main pane like the channel browser.
*/
export function NewConversation({ onCreated }: { onCreated?: (threadId: string) => void }) {
const adapter = useAdapter();
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [q, setQ] = useState('');
const [selected, setSelected] = useState<string[]>([]);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!adapter.directory) {
setLoading(false);
return;
}
let alive = true;
adapter
.directory()
.then((p) => {
if (alive) {
setPeople(p);
setError(null);
}
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter]);
const filtered = people.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const isGroup = selected.length > 1;
const canStart = selected.length >= 1 && !busy;
function toggle(id: string): void {
setSelected((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id]));
}
async function start(): Promise<void> {
if (!canStart) return;
setBusy(true);
setError(null);
try {
const res = await adapter.openThread({
participantIds: selected,
membership: isGroup ? 'group' : 'dm',
...(isGroup && name.trim() ? { subject: name.trim() } : {}),
});
onCreated?.(res.threadId);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<div className="miu-browser">
<div className="miu-browser-head">New message</div>
<div className="miu-newconv">
<input className="miu-input" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" aria-label="Search people" />
{isGroup ? (
<input className="miu-input" value={name} onChange={(e) => setName(e.target.value)} placeholder="Group name (optional)" aria-label="Group name" />
) : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
<div className="miu-browser-list">
{loading && people.length === 0 ? <div className="miu-empty">Loading</div> : null}
{!loading && filtered.length === 0 ? <div className="miu-empty">No people found.</div> : null}
{filtered.map((p) => (
<label key={p.id} className={`miu-person-row${selected.includes(p.id) ? ' is-active' : ''}`}>
<input type="checkbox" checked={selected.includes(p.id)} onChange={() => toggle(p.id)} />
<span className="miu-browser-name">{p.name}</span>
<span className="miu-pill">{p.kind}</span>
</label>
))}
</div>
<button type="button" className="miu-send" onClick={() => void start()} disabled={!canStart}>
{busy ? 'Starting…' : isGroup ? `Create group (${selected.length})` : 'Start chat'}
</button>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from '../provider';
import { Messenger } from './messenger';
import { MockAdapter } from '../adapters/mock';
function mount() {
return render(
<MessagingProvider adapter={new MockAdapter()}>
<Messenger />
</MessagingProvider>,
);
}
describe('Slack-style thread pane', () => {
it('opens a thread on 💬, posts a reply into it, and shows the reply count on the parent', async () => {
mount();
// Wait for the auto-selected thread's message to render WITH its actions (not just the sidebar
// preview), then open the thread pane via the message's reply affordance (💬).
const replyButtons = await screen.findAllByTitle('Reply in thread');
fireEvent.click(replyButtons[0]!);
expect(await screen.findByText('Thread')).toBeTruthy(); // pane header
// The pane's composer (placeholder "Reply…") — send a threaded reply.
const replyInput = screen.getByPlaceholderText('Reply…') as HTMLInputElement;
fireEvent.change(replyInput, { target: { value: 'on it' } });
// The pane has its own Send; grab the last one (pane is rendered after the main composer).
const sends = screen.getAllByText('Send');
fireEvent.click(sends[sends.length - 1]!);
// The reply shows in the pane (replies are hidden from the main thread, so this is unique)...
await waitFor(() => expect(screen.getByText('on it')).toBeTruthy());
// ...and the parent now advertises the reply count as a thread-link button in the main thread.
await waitFor(() => expect(screen.getByRole('button', { name: /1 reply/ })).toBeTruthy());
});
});
@@ -0,0 +1,60 @@
import { useMemo } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import { Composer } from './composer';
import { MessageItem } from './message-item';
/**
* The Slack-style thread side panel: the root message + its replies + a composer that posts back
* into the thread (parentInteractionId = root). Uses its own useMessages on the same thread; the
* shared adapter subscription keeps it and the main view in sync.
*/
export function ThreadPane({
threadId,
rootId,
onClose,
}: {
threadId: string;
rootId: string;
onClose: () => void;
}) {
const { messages, send, react, upload, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const root = useMemo(() => messages.find((m) => m.id === rootId), [messages, rootId]);
const replies = useMemo(() => messages.filter((m) => m.parentInteractionId === rootId), [messages, rootId]);
return (
<aside className="miu-pane">
<header className="miu-pane-head">
<span>Thread</span>
<button type="button" className="miu-pane-close" onClick={onClose} aria-label="Close thread">
</button>
</header>
<div className="miu-messages miu-pane-messages">
{root ? (
<MessageItem message={root} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(root.id)} />
) : (
<div className="miu-empty">Message not found.</div>
)}
<div className="miu-pane-divider">{replies.length} {replies.length === 1 ? 'reply' : 'replies'}</div>
{replies.map((m) => (
<MessageItem key={m.id} message={m} memberNames={memberNames} canReact={canReact} onReact={(id, e) => void react(id, e)} seen={seenIds.has(m.id)} />
))}
</div>
<Composer
members={members}
canUpload={canUpload}
upload={upload}
onSend={send}
onTyping={sendTyping}
parentInteractionId={rootId}
placeholder="Reply…"
/>
</aside>
);
}
@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import { Composer } from './composer';
import { MessageItem } from './message-item';
/**
* The main conversation view: top-level messages + composer. Replies (messages with a
* parentInteractionId) are hidden here and live in the ThreadPane — a message with replies shows a
* "N replies" link that opens it. @mentions, reactions, and attachments all work.
*/
export function Thread({
threadId,
activeRootId,
onOpenThread,
}: {
threadId: string | null;
activeRootId?: string | null;
onOpenThread?: (rootId: string) => void;
}) {
const { messages, loading, error, send, react, upload, typingUserIds, seenIds, sendTyping, canReact, canUpload } = useMessages(threadId);
const members = useMembers(threadId);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
const topLevel = useMemo(() => messages.filter((m) => !m.parentInteractionId), [messages]);
const replyCount = useMemo(() => {
const counts = new Map<string, number>();
for (const m of messages) if (m.parentInteractionId) counts.set(m.parentInteractionId, (counts.get(m.parentInteractionId) ?? 0) + 1);
return counts;
}, [messages]);
if (!threadId) {
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
}
return (
<div className={`miu-thread${activeRootId ? ' has-pane' : ''}`}>
<div className="miu-messages">
{loading && messages.length === 0 ? <div className="miu-empty">Loading</div> : null}
{error ? <div className="miu-empty miu-error">{error}</div> : null}
{topLevel.map((m) => (
<MessageItem
key={m.id}
message={m}
memberNames={memberNames}
canReact={canReact}
onReact={(id, emoji) => void react(id, emoji)}
seen={seenIds.has(m.id)}
replyCount={replyCount.get(m.id) ?? 0}
{...(onOpenThread ? { onOpenThread } : {})}
/>
))}
{typingUserIds.length > 0 ? (
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
) : null}
</div>
<Composer members={members} canUpload={canUpload} upload={upload} onSend={send} onTyping={sendTyping} />
</div>
);
}
@@ -0,0 +1,8 @@
import { runAdapterConformance } from './conformance';
import { MockAdapter } from './adapters/mock';
runAdapterConformance({
makeAdapter: () => new MockAdapter(),
seededThreadId: 'th_mock_1',
openWith: ['pp_sofia'],
});
@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import type { MessagingAdapter } from './adapter';
import type { MessageEvent } from './types';
export interface ConformanceOptions {
/** Build a fresh, isolated adapter per test. */
makeAdapter: () => Promise<MessagingAdapter> | MessagingAdapter;
/** A thread id that exists in the fixture. */
seededThreadId: string;
/** Participant ids openThread can legally be called with. */
openWith: string[];
}
/**
* The definition of a correct MessagingAdapter. Every adapter runs this.
*
* Imports vitest, so it ships from the './conformance' subpath ONLY and is never
* reachable from the main barrel. Consumers supply their own vitest.
*
* Usage from another package:
* import { runAdapterConformance } from '@insignia/iios-messaging-ui/conformance';
* runAdapterConformance({ makeAdapter: () => new MockAdapter(), seededThreadId: 'th_1', openWith: ['pp_a'] });
*/
export function runAdapterConformance(opts: ConformanceOptions): void {
const make = async () => await opts.makeAdapter();
describe('MessagingAdapter conformance', () => {
it('lists conversations', async () => {
const a = await make();
const list = await a.listConversations();
expect(Array.isArray(list)).toBe(true);
});
it('returns history for a seeded thread', async () => {
const a = await make();
const msgs = await a.history(opts.seededThreadId);
expect(Array.isArray(msgs)).toBe(true);
});
it('send resolves with a message carrying the sent text and a stable id', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'conformance hello');
expect(m.text).toBe('conformance hello');
expect(typeof m.id).toBe('string');
expect(m.id.length).toBeGreaterThan(0);
});
it('a sent message is attributed to the current actor', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'whose is this');
expect(m.actorId).toBe(a.currentActorId());
});
it('currentActorId is known BEFORE any message is sent', async () => {
// The bug this SDK exists to kill: identity must come from auth, never be
// inferred from history. A fresh adapter already knows who you are.
const a = await make();
expect(a.currentActorId()).not.toBeNull();
});
it('a sent message appears in history', async () => {
const a = await make();
await a.send(opts.seededThreadId, 'persist me');
const msgs = await a.history(opts.seededThreadId);
expect(msgs.some((m) => m.text === 'persist me')).toBe(true);
});
it('subscribe delivers a message event on send', async () => {
const a = await make();
const seen: MessageEvent[] = [];
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
await a.send(opts.seededThreadId, 'live one');
off();
const msgs = seen.filter((e) => e.kind === 'message');
expect(msgs.length).toBeGreaterThan(0);
});
it('unsubscribe stops delivery', async () => {
const a = await make();
const seen: MessageEvent[] = [];
const off = a.subscribe(opts.seededThreadId, (e) => seen.push(e));
off();
await a.send(opts.seededThreadId, 'should not be heard');
expect(seen).toHaveLength(0);
});
it('unsubscribe is idempotent', async () => {
const a = await make();
const off = a.subscribe(opts.seededThreadId, () => {});
off();
expect(() => off()).not.toThrow();
});
it('openThread returns a thread id', async () => {
const a = await make();
const { threadId } = await a.openThread({ participantIds: opts.openWith });
expect(typeof threadId).toBe('string');
expect(threadId.length).toBeGreaterThan(0);
});
it('markRead resolves', async () => {
const a = await make();
const m = await a.send(opts.seededThreadId, 'read me');
await expect(a.markRead(opts.seededThreadId, m.id)).resolves.toBeUndefined();
});
it('sendTyping does not throw', async () => {
const a = await make();
expect(() => a.sendTyping(opts.seededThreadId)).not.toThrow();
});
});
}
@@ -0,0 +1,84 @@
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { ChannelSummary, CreateChannelInput } from '../types';
export interface ChannelsState {
/** Discoverable channels (public + private-I'm-in), each flagged `joined`. */
browsable: ChannelSummary[];
loading: boolean;
error: string | null;
/** Whether the adapter implements channels at all — drives showing/hiding the channels UI. */
supported: boolean;
refetch: () => void;
create: (input: CreateChannelInput) => Promise<string>;
join: (threadId: string) => Promise<void>;
leave: (threadId: string) => Promise<void>;
}
export function useChannels(): ChannelsState {
const adapter = useAdapter();
const supported = typeof adapter.browseChannels === 'function';
const [browsable, setBrowsable] = useState<ChannelSummary[]>([]);
const [loading, setLoading] = useState(supported);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
if (!adapter.browseChannels) {
setLoading(false);
return;
}
let alive = true;
setLoading(true);
adapter
.browseChannels()
.then((list) => {
if (!alive) return;
setBrowsable(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setBrowsable([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
const create = useCallback(
async (input: CreateChannelInput) => {
if (!adapter.createChannel) throw new Error('channels not supported by this adapter');
const { threadId } = await adapter.createChannel(input);
setNonce((n) => n + 1);
return threadId;
},
[adapter],
);
const join = useCallback(
async (threadId: string) => {
if (!adapter.joinChannel) throw new Error('channels not supported by this adapter');
await adapter.joinChannel(threadId);
setNonce((n) => n + 1);
},
[adapter],
);
const leave = useCallback(
async (threadId: string) => {
if (!adapter.leaveChannel) throw new Error('channels not supported by this adapter');
await adapter.leaveChannel(threadId);
setNonce((n) => n + 1);
},
[adapter],
);
return { browsable, loading, error, supported, refetch, create, join, leave };
}
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useConversations } from './use-conversations';
import type { MessagingAdapter } from '../adapter';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useConversations', () => {
it('starts loading, then resolves the adapter list', async () => {
const { result } = renderHook(() => useConversations(), { wrapper: wrap(new MockAdapter()) });
expect(result.current.loading).toBe(true);
await waitFor(() => expect(result.current.loading).toBe(false));
// 2 DMs/groups + 2 channels I'm a member of (the un-joined public channel is browse-only).
expect(result.current.conversations).toHaveLength(4);
expect(result.current.conversations[0]!.threadId).toBe('th_mock_1');
expect(result.current.error).toBeNull();
});
it('surfaces adapter failure as error state and never throws', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'listConversations').mockRejectedValue(new Error('data door down'));
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe('data door down');
expect(result.current.conversations).toEqual([]);
});
it('refetch picks up newly opened threads', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useConversations(), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.conversations).toHaveLength(4));
await act(async () => {
await adapter.openThread({ participantIds: ['pp_dan'] });
});
await act(async () => {
result.current.refetch();
});
await waitFor(() => expect(result.current.conversations).toHaveLength(5));
});
});
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Conversation } from '../types';
export interface ConversationsState {
conversations: Conversation[];
loading: boolean;
error: string | null;
refetch: () => void;
}
export function useConversations(): ConversationsState {
const adapter = useAdapter();
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let alive = true;
setLoading(true);
adapter
.listConversations()
.then((list) => {
if (!alive) return;
setConversations(list);
setError(null);
})
.catch((e: unknown) => {
if (!alive) return;
setError(e instanceof Error ? e.message : String(e));
setConversations([]);
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, [adapter, nonce]);
const refetch = useCallback(() => setNonce((n) => n + 1), []);
return { conversations, loading, error, refetch };
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { useAdapter } from '../provider';
import type { Person } from '../types';
/** A thread's members (for @mention autocomplete + highlighting). Empty if the adapter
* doesn't implement listMembers, or while loading. */
export function useMembers(threadId: string | null): Person[] {
const adapter = useAdapter();
const [members, setMembers] = useState<Person[]>([]);
useEffect(() => {
if (!threadId || !adapter.listMembers) {
setMembers([]);
return;
}
let alive = true;
adapter
.listMembers(threadId)
.then((m) => {
if (alive) setMembers(m);
})
.catch(() => {
if (alive) setMembers([]);
});
return () => {
alive = false;
};
}, [adapter, threadId]);
return members;
}
@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import { MessagingProvider } from '../provider';
import { MockAdapter } from '../adapters/mock';
import { useMessages } from './use-messages';
import type { MessagingAdapter } from '../adapter';
import type { Message } from '../types';
const wrap = (adapter: MessagingAdapter) =>
function Wrapper({ children }: { children: ReactNode }) {
return <MessagingProvider adapter={adapter}>{children}</MessagingProvider>;
};
describe('useMessages', () => {
it('loads history for the thread', async () => {
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(new MockAdapter()) });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]!.text).toBe('Can you review the Henderson estimate?');
});
// REGRESSION: the CRM inferred actor identity by scanning for a sent message, so
// before you had spoken in a thread EVERY message rendered as not-yours.
it('marks ownership correctly before the user has sent anything', async () => {
const adapter = new MockAdapter();
await adapter.send('th_mock_1', 'an earlier message of mine');
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
// Never sent anything via the hook — ownership still resolves from currentActorId().
expect(result.current.messages[0]!.mine).toBe(false); // from pp_sofia
expect(result.current.messages[1]!.mine).toBe(true); // from me
});
it('appends an optimistic message immediately on send', async () => {
const adapter = new MockAdapter();
let release!: () => void;
vi.spyOn(adapter, 'send').mockImplementation(
() => new Promise((res) => { release = () => res({ id: 'srv_1', actorId: 'me', text: 'hi', at: '2026-07-17T10:00:00.000Z' }); }),
);
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { void result.current.send('hi'); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages[1]!.pending).toBe(true);
expect(result.current.messages[1]!.mine).toBe(true);
await act(async () => { release(); });
await waitFor(() => expect(result.current.messages[1]!.pending).toBeFalsy());
});
it('rolls back the optimistic message and reports error when send fails', async () => {
const adapter = new MockAdapter();
vi.spyOn(adapter, 'send').mockRejectedValue(new Error('offline'));
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await expect(result.current.send('doomed')).rejects.toThrow('offline');
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages.some((m) => m.text === 'doomed')).toBe(false);
expect(result.current.error).toBe('offline');
});
it('does not duplicate a message when the transport echoes it back', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
// MockAdapter.send emits a 'message' event AND resolves with the same message.
await act(async () => { await result.current.send('echo once'); });
expect(result.current.messages.filter((m) => m.text === 'echo once')).toHaveLength(1);
});
it('collects typing user ids from subscribe events', async () => {
const adapter = new MockAdapter();
let emit!: (userId: string) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (userId) => cb({ kind: 'typing', userId });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => emit('pp_sofia'));
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
});
it('unsubscribes on unmount', async () => {
const adapter = new MockAdapter();
const off = vi.fn();
vi.spyOn(adapter, 'subscribe').mockReturnValue(off);
const { unmount, result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
unmount();
expect(off).toHaveBeenCalled();
});
it('keeps a live message that arrives before history resolves', async () => {
const adapter = new MockAdapter();
let resolveHistory!: (msgs: Message[]) => void;
vi.spyOn(adapter, 'history').mockImplementation(
() => new Promise<Message[]>((res) => { resolveHistory = res; }),
);
let emit!: (m: Message) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (m) => cb({ kind: 'message', message: m });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
// A live message arrives while history() is still pending.
act(() => emit({ id: 'live_1', actorId: 'pp_sofia', text: 'ping before history', at: '2026-07-17T10:00:00.000Z' }));
// History resolves afterwards with an older message.
await act(async () => {
resolveHistory([{ id: 'hist_1', actorId: 'pp_sofia', text: 'older', at: '2026-07-17T09:00:00.000Z' }]);
});
const texts = result.current.messages.map((m) => m.text);
expect(texts).toContain('older');
expect(texts).toContain('ping before history'); // must NOT be clobbered by history load
});
it('clears a typing indicator after its TTL elapses', async () => {
vi.useFakeTimers();
try {
const adapter = new MockAdapter();
let emit!: (userId: string) => void;
vi.spyOn(adapter, 'subscribe').mockImplementation((_t, cb) => {
emit = (userId) => cb({ kind: 'typing', userId });
return () => {};
});
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // flush history microtask
act(() => emit('pp_sofia'));
expect(result.current.typingUserIds).toEqual(['pp_sofia']);
await act(async () => { await vi.advanceTimersByTimeAsync(3600); });
expect(result.current.typingUserIds).toEqual([]);
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { Attachment, Message, SendOpts } from '../types';
const TYPING_TTL_MS = 3500;
export interface UiMessage extends Message {
mine: boolean;
}
export interface MessagesState {
messages: UiMessage[];
loading: boolean;
error: string | null;
send: (content: string, opts?: SendOpts) => Promise<void>;
react: (messageId: string, emoji: string) => Promise<void>;
upload: (file: File) => Promise<Attachment>;
typingUserIds: string[];
seenIds: Set<string>;
sendTyping: () => void;
canReact: boolean;
canUpload: boolean;
}
let optimisticSeq = 0;
export function useMessages(threadId: string | null): MessagesState {
const adapter = useAdapter();
const [raw, setRaw] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [typing, setTyping] = useState<Record<string, number>>({});
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
const currentActorId = adapter.currentActorId();
const actorRef = useRef(currentActorId);
actorRef.current = currentActorId;
// Load history, then subscribe. Reconciliation is by message id, so an echoed
// send never duplicates the optimistic row.
useEffect(() => {
if (!threadId) {
setRaw([]);
setLoading(false);
return;
}
let alive = true;
setLoading(true);
setRaw([]);
setError(null);
setSeenIds(new Set());
setTyping({});
adapter
.history(threadId)
.then((h) => {
if (!alive) return;
// Merge, don't clobber: a live message can arrive via subscribe while this
// history fetch is still in flight. Blindly setting raw = h would drop it.
setRaw((live) => {
const histIds = new Set(h.map((m) => m.id));
const extras = live.filter((m) => !histIds.has(m.id));
return extras.length ? [...h, ...extras] : h;
});
setError(null);
})
.catch((e: unknown) => {
if (alive) setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (alive) setLoading(false);
});
const off = adapter.subscribe(threadId, (e) => {
if (!alive) return;
switch (e.kind) {
case 'message':
setRaw((l) => (l.some((m) => m.id === e.message.id) ? l : [...l, e.message]));
break;
case 'typing':
if (e.userId !== actorRef.current) {
setTyping((t) => ({ ...t, [e.userId]: Date.now() + TYPING_TTL_MS }));
}
break;
case 'receipt':
// Only the OTHER side reading my message counts as "seen".
if (e.actorId !== actorRef.current) {
setSeenIds((s) => (s.has(e.messageId) ? s : new Set(s).add(e.messageId)));
}
break;
case 'reaction':
setRaw((l) => l.map((m) => (m.id === e.messageId ? { ...m, reactions: e.reactions } : m)));
break;
}
});
return () => {
alive = false;
off();
};
}, [adapter, threadId]);
const messages: UiMessage[] = useMemo(
() => raw.map((m) => ({ ...m, mine: isOwnMessage(m, currentActorId) })),
[raw, currentActorId],
);
const send = useCallback(
async (content: string, opts?: SendOpts) => {
if (!threadId) return;
const tempId = `optimistic_${optimisticSeq++}`;
const optimistic: Message = {
id: tempId,
actorId: actorRef.current,
text: content,
at: new Date().toISOString(),
pending: true,
reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
setRaw((l) => [...l, optimistic]);
try {
const saved = await adapter.send(threadId, content, opts);
setError(null);
// Replace the optimistic row with the server's. If the subscribe echo already
// added the real message, just drop the optimistic one.
setRaw((l) => {
const withoutTemp = l.filter((m) => m.id !== tempId);
return withoutTemp.some((m) => m.id === saved.id) ? withoutTemp : [...withoutTemp, saved];
});
} catch (e: unknown) {
setRaw((l) => l.filter((m) => m.id !== tempId));
setError(e instanceof Error ? e.message : String(e));
throw e;
}
},
[adapter, threadId],
);
const react = useCallback(
async (messageId: string, emoji: string) => {
if (!threadId || !adapter.react) return;
await adapter.react(threadId, messageId, emoji);
},
[adapter, threadId],
);
const upload = useCallback(
async (file: File): Promise<Attachment> => {
if (!adapter.upload) throw new Error('uploads are not supported by this adapter');
return adapter.upload(file);
},
[adapter],
);
const sendTyping = useCallback(() => {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
// The newest acknowledged (non-pending) message id — what we report as read.
const lastReadableId = useMemo(() => {
for (let i = raw.length - 1; i >= 0; i--) {
if (!raw[i]!.pending) return raw[i]!.id;
}
return null;
}, [raw]);
// Report my read of the newest message (drives the other side's "seen" tick).
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.
useEffect(() => {
if (!threadId || !lastReadableId) return;
void adapter.markRead(threadId, lastReadableId).catch(() => {});
}, [adapter, threadId, lastReadableId]);
const typingUserIds = useMemo(() => {
const now = Date.now();
return Object.entries(typing)
.filter(([, exp]) => exp > now)
.map(([u]) => u);
}, [typing]);
// Expire stale typing entries. Bumping `typing` to a new reference forces the
// memo above to recompute with a fresh `now`, dropping entries past their TTL.
// (A bump of unrelated state can't do this — the memo is keyed on `typing`, so it
// would return its cached array and the indicator would stick forever.)
useEffect(() => {
if (typingUserIds.length === 0) return;
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
return () => clearTimeout(t);
}, [typingUserIds.length, typing]);
// Only my messages that the other side has read.
const seenMine = useMemo(() => {
const out = new Set<string>();
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
return out;
}, [seenIds, messages]);
return {
messages,
loading,
error,
send,
react,
upload,
typingUserIds,
seenIds: seenMine,
sendTyping,
canReact: typeof adapter.react === 'function',
canUpload: typeof adapter.upload === 'function',
};
}
@@ -0,0 +1,32 @@
import type { MailAttachment, 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, optionally with one attachment. */
mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void>;
// ── 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<MailAttachment>;
// ── 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, attachments?: MailAttachment[]): Promise<void>;
/** External email (SMTP) to an address. */
composeExternal?(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void>;
}
@@ -0,0 +1,174 @@
import { useCallback, useEffect, useState } from 'react';
import { useInboxAdapter } from './provider';
import type { InboxItem, InboxState, MailAttachment, 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, attachment?: MailAttachment) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
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, attachment?: MailAttachment) => {
if (!threadId) return;
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, canAttach: typeof adapter.uploadAttachment === 'function', upload, refetch };
}
export interface ComposeState {
supported: boolean;
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, attachments?: MailAttachment[]) => Promise<void>;
canAttach: boolean;
upload: (file: File) => Promise<MailAttachment>;
}
/** 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, attachments?: MailAttachment[]) => {
if (!adapter.composeInternal) throw new Error('compose is not supported by this adapter');
await adapter.composeInternal(recipientUserId, subject, text, attachments);
},
[adapter],
);
const sendExternal = useCallback(
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, 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, canAttach: typeof adapter.uploadAttachment === 'function', upload };
}
@@ -0,0 +1,97 @@
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);
});
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('<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('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'));
// 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,328 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { ModalPortal } from '../components/modal-portal';
import { useCompose, useInbox, useMailThread } from './hooks';
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' },
{ 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&apos;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, canAttach, upload } = useMailThread(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [pending, setPending] = useState<MailAttachment | null>(null);
const [attaching, setAttaching] = useState(false);
const [attachErr, setAttachErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
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<void> {
e.preventDefault();
const text = draft.trim();
if ((!text && !pending) || sending) return;
const att = pending;
setDraft('');
setPending(null);
setSending(true);
try {
await reply(text, att ?? undefined);
} catch {
setDraft(text);
setPending(att);
} 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" />
) : m.text ? (
<div className="miu-mail-text">{m.text}</div>
) : null}
{m.attachment ? (
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
) : null}
</article>
))}
</div>
<form className="miu-composer" onSubmit={submit}>
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
{pending ? (
<div className="miu-attach-pending">
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment"></button>
</div>
) : null}
<div className="miu-composer-row">
{canAttach ? (
<>
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-attach-btn" onClick={() => fileRef.current?.click()} disabled={attaching || !!pending} title="Attach a file" aria-label="Attach a file">
{attaching ? '…' : '📎'}
</button>
</>
) : null}
<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() && !pending) || 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 [attachments, setAttachments] = useState<MailAttachment[]>([]);
const [attaching, setAttaching] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && (!!body.trim() || attachments.length > 0) && !busy && !attaching;
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || attachments.length >= 10) return;
setAttaching(true);
setErr(null);
try {
const ref = await compose.upload(file);
setAttachments((a) => [...a, ref]);
} catch (e2) {
setErr(e2 instanceof Error ? e2.message : String(e2));
} finally {
setAttaching(false);
}
}
async function send(): Promise<void> {
if (!canSend) return;
setBusy(true);
setErr(null);
const atts = attachments.length > 0 ? attachments : undefined;
try {
if (mode === 'internal') await compose.sendInternal(recipient, subject.trim(), body.trim(), atts);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), atts);
onSent();
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
return (
<ModalPortal>
<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>
{compose.canAttach ? (
<div className="miu-field">
<span className="miu-field-lbl">Attachments</span>
<div className="miu-attach-list">
{attachments.map((a, i) => (
<span key={`${a.contentRef}-${i}`} className="miu-attach-pending">
<span className="miu-attach-chip">📎 {a.filename ?? 'attachment'}{a.sizeBytes ? ` · ${fmtBytes(a.sizeBytes)}` : ''}</span>
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment"></button>
</span>
))}
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
{attaching ? 'Uploading…' : '📎 Attach'}
</button>
</div>
</div>
) : null}
{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>
</ModalPortal>
);
}
@@ -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';
}
+44
View File
@@ -0,0 +1,44 @@
// Public API. Components land in Plan 2; adapters/kernel in Plan 3.
//
// `runAdapterConformance` is deliberately NOT exported here — it imports vitest and
// ships from the './conformance' subpath so consumers never pull a test runner into
// their production bundle.
export { MessagingProvider, useAdapter } from './provider';
export { useConversations } from './hooks/use-conversations';
export { useMessages } from './hooks/use-messages';
export { useChannels } from './hooks/use-channels';
export { useMembers } from './hooks/use-members';
export { isOwnMessage } from './types';
// Rendered UI. Pair with the './styles.css' export (or override the --miu-* tokens).
export { Messenger } from './components/messenger';
export { ConversationList } from './components/conversation-list';
export { ChannelBrowser } from './components/channel-browser';
export { Thread } from './components/thread';
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 { ConversationsState } from './hooks/use-conversations';
export type { MessagesState, UiMessage } from './hooks/use-messages';
export type { ChannelsState } from './hooks/use-channels';
export type {
Attachment,
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
Person,
Reaction,
SendOpts,
Unsubscribe,
} from './types';
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MessagingProvider } from './provider';
import { Messenger } from './components/messenger';
import { MockAdapter } from './adapters/mock';
import { insertMention, resolveMentions, trailingMentionQuery } from './mentions';
import type { Person } from './types';
const people: Person[] = [
{ id: 'pp_sofia', name: 'Sofia Ramirez', kind: 'staff' },
{ id: 'pp_dan', name: 'Dan Whitaker', kind: 'staff' },
];
describe('mention helpers', () => {
it('trailingMentionQuery finds the @token being typed', () => {
expect(trailingMentionQuery('hey @Sof')).toBe('Sof');
expect(trailingMentionQuery('@')).toBe('');
expect(trailingMentionQuery('no mention here')).toBeNull();
expect(trailingMentionQuery('done @Sofia Ramirez ')).toBeNull(); // completed, trailing space
});
it('insertMention replaces the trailing query, keeping the boundary', () => {
expect(insertMention('hey @Sof', 'Sofia Ramirez')).toBe('hey @Sofia Ramirez ');
expect(insertMention('@ch', 'channel')).toBe('@channel ');
});
it('resolveMentions maps names to ids; @channel expands to everyone', () => {
expect(resolveMentions('ping @Sofia Ramirez', people)).toEqual(['pp_sofia']);
expect(resolveMentions('nobody here', people)).toEqual([]);
expect(resolveMentions('@channel ship it', people).sort()).toEqual(['pp_dan', 'pp_sofia']);
});
});
describe('<Thread /> @mention autocomplete', () => {
it('picking a suggestion inserts the name and send carries the mention id', async () => {
const adapter = new MockAdapter();
const sendSpy = vi.spyOn(adapter, 'send');
render(
<MessagingProvider adapter={adapter}>
<Messenger />
</MessagingProvider>,
);
const input = (await screen.findByLabelText('Message')) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'hey @Sof' } });
// The suggestion (role=option) is distinct from the sidebar row of the same name.
fireEvent.click(await screen.findByRole('option', { name: 'Sofia Ramirez' }));
expect(input.value).toBe('hey @Sofia Ramirez ');
fireEvent.click(screen.getByText('Send'));
await waitFor(() => expect(sendSpy).toHaveBeenCalled());
const opts = sendSpy.mock.calls[0]![2];
expect(opts?.mentions).toContain('pp_sofia');
});
});
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import type { Person } from './types';
/** Room-wide mention tokens, always offered alongside members. */
export const SPECIAL_MENTIONS = ['channel', 'here'];
function esc(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** The @token currently being typed at the end of the draft (caret-at-end), or null. */
export function trailingMentionQuery(draft: string): string | null {
const m = draft.match(/(?:^|\s)@([\w]*)$/);
return m ? (m[1] ?? '') : null;
}
/** Replace the trailing @query with `@insert ` (keeping the leading boundary). */
export function insertMention(draft: string, insert: string): string {
return draft.replace(/(^|\s)@([\w]*)$/, (_full, lead: string) => `${lead}@${insert} `);
}
/** Resolve the opaque mention userId list from the final text + known members. */
export function resolveMentions(text: string, members: Person[]): string[] {
const ids = new Set<string>();
for (const p of members) if (text.includes(`@${p.name}`)) ids.add(p.id);
if (/@channel\b/.test(text) || /@here\b/.test(text)) for (const p of members) ids.add(p.id);
return [...ids];
}
/** Render text with @mentions (member names + @channel/@here) wrapped for highlighting. */
export function highlightMentions(text: string, memberNames: string[]): ReactNode[] {
// Longest-first so "@Sofia Ramirez" wins over a bare "@Sofia".
const names = [...new Set([...memberNames, ...SPECIAL_MENTIONS])].filter(Boolean).sort((a, b) => b.length - a.length);
if (names.length === 0) return [text];
const re = new RegExp(`@(${names.map(esc).join('|')})`, 'g');
const out: ReactNode[] = [];
let last = 0;
let key = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) out.push(text.slice(last, m.index));
out.push(
<span key={`m${key++}`} className="miu-mention">
{m[0]}
</span>,
);
last = m.index + m[0].length;
}
if (last < text.length) out.push(text.slice(last));
return out.length > 0 ? out : [text];
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MessagingProvider, useAdapter } from './provider';
import { MockAdapter } from './adapters/mock';
function ShowActor() {
const adapter = useAdapter();
return <span>{adapter.currentActorId()}</span>;
}
describe('MessagingProvider', () => {
it('supplies the injected adapter to descendants', () => {
render(
<MessagingProvider adapter={new MockAdapter()}>
<ShowActor />
</MessagingProvider>,
);
expect(screen.getByText('me')).toBeDefined();
});
it('throws a helpful error when a hook is used outside the provider', () => {
// React logs the error boundary trace; that noise is expected.
expect(() => render(<ShowActor />)).toThrow(/useAdapter must be used within a <MessagingProvider>/);
});
});
@@ -0,0 +1,22 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { MessagingAdapter } from './adapter';
const AdapterContext = createContext<MessagingAdapter | null>(null);
export function MessagingProvider({
adapter,
children,
}: {
adapter: MessagingAdapter;
children: ReactNode;
}) {
return <AdapterContext.Provider value={adapter}>{children}</AdapterContext.Provider>;
}
/** Access the host-injected 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 useAdapter(): MessagingAdapter {
const adapter = useContext(AdapterContext);
if (!adapter) throw new Error('useAdapter must be used within a <MessagingProvider>');
return adapter;
}
@@ -0,0 +1,9 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
describe('test infrastructure', () => {
it('renders React components in jsdom', () => {
render(<div>messaging-ui</div>);
expect(screen.getByText('messaging-ui')).toBeDefined();
});
});
+944
View File
@@ -0,0 +1,944 @@
/* Default theme for @insignia/iios-messaging-ui. Everything is driven by CSS variables
scoped to .miu-messenger, so a host restyles by overriding the tokens no component edits. */
.miu-messenger {
--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;
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-sidebar {
width: 300px;
flex-shrink: 0;
border-right: 1px solid var(--miu-border);
overflow-y: auto;
background: var(--miu-panel);
}
.miu-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* conversation list */
.miu-convlist {
list-style: none;
margin: 0;
padding: 0;
}
.miu-convrow {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 14px;
border: none;
border-bottom: 1px solid var(--miu-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
}
.miu-convrow:hover {
background: var(--miu-panel-2);
}
.miu-convrow.is-active {
background: var(--miu-panel-2);
}
.miu-avatar {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-convrow-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.miu-convrow-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-convrow-preview {
color: var(--miu-muted);
font-size: 12.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.miu-badge {
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
/* thread */
.miu-thread {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.miu-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.miu-msg {
display: flex;
flex-direction: column;
align-items: flex-start;
max-width: 78%;
}
.miu-msg.is-mine {
align-self: flex-end;
align-items: flex-end;
}
.miu-bubble {
padding: 8px 12px;
border-radius: 14px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
white-space: pre-wrap;
word-break: break-word;
}
.miu-msg.is-mine .miu-bubble {
border: none;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-msg.is-pending {
opacity: 0.6;
}
.miu-bubble-row {
display: flex;
align-items: center;
gap: 4px;
}
.miu-msg.is-mine .miu-bubble-row {
flex-direction: row-reverse;
}
.miu-msg-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
}
.miu-thread-link {
align-self: flex-start;
margin-top: 3px;
padding: 3px 9px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.miu-msg.is-mine .miu-thread-link {
align-self: flex-end;
}
.miu-react-wrap {
position: relative;
flex-shrink: 0;
}
.miu-react-btn {
border: none;
background: none;
cursor: pointer;
opacity: 0;
font-size: 14px;
padding: 2px;
transition: opacity 0.12s;
}
.miu-msg:hover .miu-react-btn {
opacity: 0.6;
}
.miu-react-btn:hover {
opacity: 1;
}
.miu-react-picker {
position: absolute;
/* Open downward + right-aligned so it never clips against the top of the scroll area
(the reported bug) or spills past the right edge. */
top: 100%;
right: 0;
margin-top: 4px;
display: flex;
gap: 2px;
padding: 4px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
z-index: 5;
width: max-content;
}
/* On my own (right-aligned) messages, anchor the picker to the left instead so it stays in view. */
.miu-msg.is-mine .miu-react-picker {
right: auto;
left: 0;
}
.miu-react-emoji {
border: none;
background: none;
cursor: pointer;
font-size: 16px;
padding: 2px 4px;
border-radius: 6px;
}
.miu-react-emoji:hover {
background: var(--miu-panel-2);
}
.miu-reactions {
display: flex;
gap: 4px;
margin-top: 3px;
}
.miu-reaction {
font-size: 12px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
cursor: pointer;
}
.miu-reaction.is-mine {
border-color: var(--miu-accent);
}
/* attachments */
.miu-att-img-link {
display: inline-block;
margin-top: 4px;
}
.miu-att-img {
max-width: 260px;
max-height: 220px;
border-radius: 8px;
border: 1px solid var(--miu-border);
}
.miu-att-file {
display: inline-block;
margin-top: 4px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
text-decoration: none;
font-size: 13px;
}
.miu-seen {
font-size: 11px;
color: var(--miu-muted);
margin-top: 2px;
}
.miu-typing {
font-size: 12.5px;
color: var(--miu-muted);
font-style: italic;
}
/* mentions */
.miu-mention {
color: var(--miu-accent);
font-weight: 600;
}
.miu-msg.is-mine .miu-bubble .miu-mention {
color: var(--miu-accent-text);
text-decoration: underline;
}
/* composer */
.miu-composer {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--miu-border);
}
.miu-composer-row {
display: flex;
gap: 8px;
}
.miu-file-input {
display: none;
}
.miu-attach-btn {
flex-shrink: 0;
width: 38px;
border-radius: 10px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
cursor: pointer;
font-size: 16px;
}
.miu-staged {
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
padding: 5px 10px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
font-size: 13px;
}
.miu-staged-x {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
padding: 0;
line-height: 1;
}
/* inbox mail attachments */
.miu-attach-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--miu-border);
background: var(--miu-panel-2);
color: var(--miu-text);
font-size: 12.5px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.miu-mail-msg .miu-attach-chip {
margin-top: 6px;
}
.miu-attach-pending {
display: inline-flex;
align-items: center;
gap: 6px;
}
.miu-attach-x {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
padding: 0 2px;
line-height: 1;
font-size: 12px;
}
.miu-attach-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.miu-suggest {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(100% - 4px);
margin: 0;
padding: 4px;
list-style: none;
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--miu-border);
border-radius: var(--miu-radius);
background: var(--miu-panel);
box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.6);
z-index: 5;
}
.miu-suggest-item {
display: block;
width: 100%;
text-align: left;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--miu-text);
font: inherit;
cursor: pointer;
}
.miu-suggest-item:hover {
background: var(--miu-panel-2);
}
.miu-input {
flex: 1;
padding: 9px 12px;
border-radius: 10px;
border: 1px solid var(--miu-border);
background: var(--miu-panel);
color: var(--miu-text);
font: inherit;
outline: none;
}
.miu-input:focus {
border-color: var(--miu-accent);
}
.miu-send {
padding: 0 16px;
border-radius: 10px;
border: none;
font-weight: 700;
cursor: pointer;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* sidebar sections + channel browser */
.miu-section {
border-bottom: 1px solid var(--miu-border);
}
.miu-section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--miu-muted);
}
.miu-section-add {
border: none;
background: transparent;
color: var(--miu-muted);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.miu-section-add:hover {
color: var(--miu-text);
}
.miu-empty-sm {
padding: 8px 14px 12px;
text-align: left;
font-size: 12.5px;
}
.miu-browser {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px;
gap: 14px;
overflow-y: auto;
}
.miu-browser-head {
font-size: 16px;
font-weight: 700;
}
.miu-channel-create {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px solid var(--miu-border);
border-radius: var(--miu-radius);
background: var(--miu-panel);
}
.miu-channel-vis {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--miu-muted);
}
.miu-channel-vis label {
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
/* new conversation (DM / group) picker */
.miu-newconv {
display: flex;
flex-direction: column;
gap: 10px;
}
/* Submit buttons in the stacked create/compose forms size to content, not full width. */
.miu-newconv .miu-send,
.miu-channel-create .miu-send {
align-self: flex-start;
height: 38px;
}
.miu-person-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--miu-border);
border-radius: 10px;
background: var(--miu-panel);
cursor: pointer;
}
.miu-person-row:hover {
border-color: var(--miu-accent);
}
.miu-person-row.is-active {
border-color: var(--miu-accent);
background: color-mix(in srgb, var(--miu-accent) 10%, var(--miu-panel));
}
.miu-person-row .miu-browser-name {
flex: 1 1 auto;
}
.miu-browser-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.miu-browser-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--miu-border);
border-radius: var(--miu-radius);
background: var(--miu-panel);
}
.miu-channel-glyph {
width: 30px;
height: 30px;
flex-shrink: 0;
border-radius: 8px;
display: grid;
place-items: center;
font-weight: 700;
color: var(--miu-muted);
background: var(--miu-panel-2);
}
.miu-browser-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.miu-browser-name {
font-weight: 600;
}
.miu-browser-topic {
font-size: 12.5px;
color: var(--miu-muted);
}
.miu-browser-meta {
font-size: 11.5px;
color: var(--miu-muted);
}
.miu-join {
flex-shrink: 0;
padding: 5px 14px;
border-radius: 8px;
border: none;
font-weight: 700;
cursor: pointer;
color: var(--miu-accent-text);
background: var(--miu-accent);
}
.miu-browser-joined {
flex-shrink: 0;
font-size: 12px;
color: var(--miu-muted);
}
/* thread pane (Slack-style) */
.miu-pane {
width: 340px;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--miu-border);
background: var(--miu-panel);
}
.miu-pane-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--miu-border);
font-weight: 700;
}
.miu-pane-close {
border: none;
background: none;
color: var(--miu-muted);
cursor: pointer;
font-size: 15px;
}
.miu-pane-messages {
background: var(--miu-bg);
}
.miu-pane-divider {
font-size: 11.5px;
color: var(--miu-muted);
text-align: center;
margin: 4px 0;
padding-bottom: 4px;
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 portaled to <body>, so carry SDK theme-token defaults here (the host's
own token mapping + a synchronous copy on the portal root override these). */
.miu-portal {
--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;
}
.miu-modal-overlay {
position: fixed;
inset: 0;
z-index: 2147483000;
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 */
.miu-empty {
padding: 24px;
color: var(--miu-muted);
text-align: center;
}
.miu-thread-empty {
margin: auto;
}
.miu-error {
color: #f0563f;
}
@@ -0,0 +1,8 @@
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// @testing-library/react auto-registers cleanup only when afterEach is a global.
// We run with globals: false, so register it explicitly.
afterEach(() => {
cleanup();
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { isOwnMessage } from './types';
import type { Message } from './types';
const base: Message = {
id: 'm1',
actorId: 'actor_a',
text: 'hello',
at: '2026-07-17T10:00:00.000Z',
};
describe('isOwnMessage', () => {
it('is true when the message actor matches the current actor', () => {
expect(isOwnMessage(base, 'actor_a')).toBe(true);
});
it('is false when the actors differ', () => {
expect(isOwnMessage(base, 'actor_b')).toBe(false);
});
it('is false when the current actor is unknown', () => {
expect(isOwnMessage(base, null)).toBe(false);
});
it('is false when the message has no actor', () => {
expect(isOwnMessage({ ...base, actorId: null }, 'actor_a')).toBe(false);
});
});
+87
View File
@@ -0,0 +1,87 @@
// Domain types for the messaging UI. Zero imports on purpose: this file must never
// reach for a transport package. Adapters map their own DTOs onto these.
export type Membership = 'dm' | 'group' | 'channel';
export type ChannelVisibility = 'public' | 'private';
export interface Person {
id: string;
name: string;
kind: 'staff' | 'customer';
}
export interface Conversation {
threadId: string;
title: string;
subject: string | null;
membership: Membership | null;
participants: string[];
unread: number;
lastMessage?: string;
lastAt?: string;
/** Channel description (channels only). */
topic?: string | null;
}
/** A discoverable channel (from browseChannels) — includes ones the caller has NOT joined. */
export interface ChannelSummary {
threadId: string;
name: string;
topic: string | null;
visibility: ChannelVisibility;
memberCount: number;
/** True if the current user is already a member. */
joined: boolean;
}
export interface CreateChannelInput {
name: string;
topic?: string;
visibility: ChannelVisibility;
}
export interface Reaction {
emoji: string;
count: number;
mine: boolean;
}
export interface Attachment {
url: string;
mime: string;
name: string;
}
export interface Message {
id: string;
actorId: string | null;
text: string;
at: string;
parentInteractionId?: string | null;
reactions?: Reaction[];
attachment?: Attachment;
/** True only when the transport is optimistic-local and not yet acknowledged. */
pending?: boolean;
}
export interface SendOpts {
parentInteractionId?: string;
attachment?: Attachment;
/** Opaque userId notify-list (from @mentions). The app parses "@"; the kernel just forwards it. */
mentions?: string[];
}
export type MessageEvent =
| { kind: 'message'; message: Message }
| { kind: 'typing'; userId: string }
| { kind: 'receipt'; messageId: string; actorId: string }
| { kind: 'reaction'; messageId: string; reactions: Reaction[] };
export type Unsubscribe = () => void;
/** Ownership is a pure function of explicit identity never inferred from history.
* See the spec: inferring it is the bug this SDK exists partly to kill. */
export function isOwnMessage(message: Message, currentActorId: string | null): boolean {
return currentActorId !== null && message.actorId !== null && message.actorId === currentActorId;
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"types": ["react"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'tsup';
export default defineConfig({
// `src/adapters/mock.ts` and `src/conformance.ts` are added as separate entries
// in later tasks (they don't exist yet). `conformance` in particular is its own
// 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
// production build.
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'],
// 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/mock-inbox.ts', 'src/adapters/kernel-client.ts', 'src/conformance.ts'] },
clean: true,
external: ['react', 'react-dom', 'vitest', '@insignia/iios-kernel-client'],
});
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
// This package owns its vitest config on purpose. The ROOT config includes only
// `.ts` (never `.tsx`) and provisions a Postgres DB via globalSetup for every run —
// a pure-UI package must not drag a database along, and its tests are .tsx.
export default defineConfig({
test: {
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
setupFiles: ['./src/test-setup.ts'],
globals: false,
},
});
@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "IiosProviderCredential" (
"id" TEXT NOT NULL,
"scopeId" TEXT NOT NULL,
"providerType" TEXT NOT NULL,
"cipherText" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"authTag" TEXT NOT NULL,
"keyVersion" INTEGER NOT NULL DEFAULT 1,
"displayHints" JSONB,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IiosProviderCredential_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IiosProviderCredential_scopeId_idx" ON "IiosProviderCredential"("scopeId");
-- CreateIndex
CREATE UNIQUE INDEX "IiosProviderCredential_scopeId_providerType_key" ON "IiosProviderCredential"("scopeId", "providerType");
-- AddForeignKey
ALTER TABLE "IiosProviderCredential" ADD CONSTRAINT "IiosProviderCredential_scopeId_fkey" FOREIGN KEY ("scopeId") REFERENCES "IiosScope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -317,10 +317,33 @@ model IiosScope {
callbacks IiosCallbackRequest[]
notificationSubscriptions IiosNotificationSubscription[]
messageTemplates IiosMessageTemplate[]
providerCredentials IiosProviderCredential[]
@@index([orgId, appId, tenantId])
}
/// A tenant's own credentials for an egress provider (BYO: Twilio SMS, own SMTP, …).
/// The secret is sealed with AES-256-GCM under the platform key (IIOS_CRED_KEY); only
/// `displayHints` (non-secret, e.g. from-number / SID last-4) is ever read back out.
/// One row per (scope, providerType). Resolved at send time by the channel's provider.
model IiosProviderCredential {
id String @id @default(cuid())
scopeId String
scope IiosScope @relation(fields: [scopeId], references: [id], onDelete: Cascade)
providerType String // TWILIO_SMS | SMTP | WHATSAPP_CLOUD | …
cipherText String // base64(AES-256-GCM ciphertext of the JSON config)
iv String // base64 nonce
authTag String // base64 GCM auth tag
keyVersion Int @default(1)
displayHints Json? // non-secret display fields (fromNumber, sidLast4, …)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scopeId, providerType])
@@index([scopeId])
}
/// A channel-specific handle (phone/email/portal user). NOT identity — stays
/// UNVERIFIED until MDM resolves it; no silent merge.
model IiosSourceHandle {
@@ -2,15 +2,20 @@ import { Module } from '@nestjs/common';
import { MediaModule } from '../media/media.module';
import { CapabilityProviderRegistry } from './capability.registry';
import { CapabilityBroker } from './capability.broker';
import { ProviderCredentialService } from './provider-credential.service';
import { ProviderCredentialController } from './provider-credential.controller';
/**
* The governed egress boundary (P9 slice 1). Exports the broker + registry so the
* outbound layer routes all sends through policy + obligations + a pluggable
* provider. PLATFORM_PORTS (for the opa gate) comes from the @Global PlatformModule.
* Also owns per-scope BYO provider credentials (Twilio SMS, ) sealed at rest and
* resolved by the channel providers at send time.
*/
@Module({
imports: [MediaModule],
providers: [CapabilityProviderRegistry, CapabilityBroker],
exports: [CapabilityBroker, CapabilityProviderRegistry],
controllers: [ProviderCredentialController],
providers: [CapabilityProviderRegistry, CapabilityBroker, ProviderCredentialService],
exports: [CapabilityBroker, CapabilityProviderRegistry, ProviderCredentialService],
})
export class CapabilityModule {}
@@ -4,6 +4,9 @@ import { SandboxProvider } from './sandbox.provider';
import { HttpProvider } from './http.provider';
import { EmailProvider } from './email.provider';
import { SmtpProvider, smtpFallbackFromEnv, smtpIdentityFromEnv, storageResolver } from './smtp.provider';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
import { credKeyFromEnv } from './secret-crypto';
import { ProviderCredentialService } from './provider-credential.service';
import { STORAGE_PORT, type StoragePort } from '../media/storage.port';
const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
@@ -21,7 +24,10 @@ const DEFAULT_CHANNELS = ['WEBHOOK', 'EMAIL', 'SMS', 'WHATSAPP', 'PORTAL'];
export class CapabilityProviderRegistry {
private readonly byChannel = new Map<string, CapabilityProvider>();
constructor(@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort) {
constructor(
@Optional() @Inject(STORAGE_PORT) private readonly storage?: StoragePort,
@Optional() private readonly credentials?: ProviderCredentialService,
) {
for (const ch of DEFAULT_CHANNELS) this.register(new SandboxProvider([ch]));
for (const ch of DEFAULT_CHANNELS) {
const url = process.env[`IIOS_PROVIDER_URL_${ch}`];
@@ -36,6 +42,12 @@ export class CapabilityProviderRegistry {
const resolver = this.storage ? storageResolver(this.storage) : undefined;
this.register(new SmtpProvider(smtp, smtpFallbackFromEnv() ?? undefined, undefined, resolver));
}
// BYO SMS via each tenant's own Twilio creds (resolved per scope at send time). Registered
// only when the platform key + credential store are present — else SMS stays on the sandbox.
if (credKeyFromEnv() && this.credentials) {
const creds = this.credentials;
this.register(new TwilioSmsProvider((scopeId) => creds.resolve(scopeId, 'TWILIO_SMS') as Promise<TwilioCreds | null>));
}
}
register(provider: CapabilityProvider): void {
@@ -0,0 +1,54 @@
import { BadRequestException, Body, Controller, Get, Headers, Param, Put } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { ProviderCredentialService } from './provider-credential.service';
import { TwilioCredentialsDto } from './provider-credential.dto';
const SUPPORTED = new Set(['TWILIO_SMS']);
/**
* Per-scope BYO integration credentials. The caller's attested session decides the scope, so a
* tenant can only read/write ITS OWN provider credentials. The secret is sealed by the service;
* GET returns a masked status (never the token). be-crm gates this behind tenant-admin policy.
*/
@Controller('v1/providers')
export class ProviderCredentialController {
constructor(
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
private readonly credentials: ProviderCredentialService,
) {}
@Put(':providerType/credentials')
async put(
@Param('providerType') providerType: string,
@Body() body: TwilioCredentialsDto,
@Headers('authorization') authorization?: string,
) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
await this.credentials.upsert(scope.id, providerType, {
accountSid: body.accountSid,
authToken: body.authToken,
fromNumber: body.fromNumber,
});
return this.credentials.status(scope.id, providerType);
}
@Get(':providerType/credentials')
async get(@Param('providerType') providerType: string, @Headers('authorization') authorization?: string) {
this.assertSupported(providerType);
const scope = await this.actors.resolveScope(this.principal(authorization));
return this.credentials.status(scope.id, providerType);
}
private assertSupported(providerType: string): void {
if (!SUPPORTED.has(providerType)) throw new BadRequestException(`unsupported providerType: ${providerType}`);
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
/**
* PUT /v1/providers/TWILIO_SMS/credentials a tenant's own Twilio credentials. Step 1 supports
* TWILIO_SMS only; when a second provider (SMTP, ) is added, switch to a per-type validated body.
*/
export class TwilioCredentialsDto {
@IsString() @IsNotEmpty() accountSid!: string;
@IsString() @IsNotEmpty() authToken!: string;
@IsString() @IsNotEmpty() fromNumber!: string;
}
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProviderCredentialService } from './provider-credential.service';
const KEY_B64 = Buffer.alloc(32, 7).toString('base64');
/** Minimal in-memory stand-in for prisma.iiosProviderCredential (upsert/findUnique). */
function fakePrisma() {
const rows = new Map<string, Record<string, unknown>>();
const k = (scopeId: string, providerType: string) => `${scopeId}::${providerType}`;
return {
_rows: rows,
iiosProviderCredential: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async upsert({ where, create, update }: any) {
const key = k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType);
const existing = rows.get(key);
const row = existing ? { ...existing, ...update } : { ...create };
rows.set(key, row);
return row;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async findUnique({ where }: any) {
return rows.get(k(where.scopeId_providerType.scopeId, where.scopeId_providerType.providerType)) ?? null;
},
},
};
}
function make(prisma: ReturnType<typeof fakePrisma>, env: NodeJS.ProcessEnv): ProviderCredentialService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const s = new ProviderCredentialService(prisma as any);
s.env = env;
return s;
}
describe('ProviderCredentialService', () => {
let prisma: ReturnType<typeof fakePrisma>;
let svc: ProviderCredentialService;
beforeEach(() => {
prisma = fakePrisma();
svc = make(prisma, { IIOS_CRED_KEY: KEY_B64 } as NodeJS.ProcessEnv);
});
it('seals the secret at rest — plaintext token never stored', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' });
const stored = JSON.stringify([...prisma._rows.values()]);
expect(stored).not.toContain('tok_secret');
expect(stored).toContain('cipherText');
});
it('resolves back the exact config it sealed', async () => {
const cfg = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
await svc.upsert('scope_1', 'TWILIO_SMS', cfg);
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toEqual(cfg);
});
it('status is masked — reveals hints, never the token', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC1234567', authToken: 'tok_secret', fromNumber: '+15550001111' });
const status = await svc.status('scope_1', 'TWILIO_SMS');
expect(status).toMatchObject({ configured: true, enabled: true, hints: { fromNumber: '+15550001111', sidLast4: '4567' } });
expect(JSON.stringify(status)).not.toContain('tok_secret');
});
it('reports not-configured for an unknown scope', async () => {
expect(await svc.status('nope', 'TWILIO_SMS')).toEqual({ configured: false });
expect(await svc.resolve('nope', 'TWILIO_SMS')).toBeNull();
});
it('does not resolve a disabled credential', async () => {
await svc.upsert('scope_1', 'TWILIO_SMS', { accountSid: 'AC123', authToken: 't', fromNumber: '+1' }, { enabled: false });
expect(await svc.resolve('scope_1', 'TWILIO_SMS')).toBeNull();
});
it('throws when the platform key is absent (fail closed, never store plaintext)', async () => {
const noKey = make(prisma, {} as NodeJS.ProcessEnv);
await expect(noKey.upsert('s', 'TWILIO_SMS', { accountSid: 'A', authToken: 't', fromNumber: '+1' })).rejects.toThrow(/IIOS_CRED_KEY/);
});
});
@@ -0,0 +1,70 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { credKeyFromEnv, sealSecret, openSecret, type SealedSecret } from './secret-crypto';
/** A provider config is an opaque JSON bag; each provider knows its own shape. */
export type ProviderConfig = Record<string, unknown>;
export interface CredentialStatus {
configured: boolean;
enabled?: boolean;
hints?: Record<string, unknown>;
}
/** Non-secret display fields surfaced by `status` — NEVER the secret itself. */
function hintsFor(providerType: string, config: ProviderConfig): Record<string, unknown> {
if (providerType === 'TWILIO_SMS') {
const sid = String(config.accountSid ?? '');
return { fromNumber: config.fromNumber ?? null, sidLast4: sid.slice(-4) };
}
return {};
}
/**
* The tenant's BYO integration credentials, one row per (scope, providerType). The secret is
* sealed with AES-256-GCM under the platform key before it touches the DB; only non-secret
* `displayHints` are ever read back. `resolve` decrypts on demand at send time; `status` never
* returns the secret. Fail-closed: no platform key upsert throws (we refuse to store plaintext).
*/
@Injectable()
export class ProviderCredentialService {
/** Overridable in tests; defaults to the process env (holds IIOS_CRED_KEY). */
env: NodeJS.ProcessEnv = process.env;
constructor(private readonly prisma: PrismaService) {}
async upsert(scopeId: string, providerType: string, config: ProviderConfig, opts?: { enabled?: boolean }): Promise<void> {
const key = credKeyFromEnv(this.env);
if (!key) throw new BadRequestException('IIOS_CRED_KEY is not configured — cannot store integration credentials');
const sealed = sealSecret(JSON.stringify(config), key);
const enabled = opts?.enabled ?? true;
const displayHints = hintsFor(providerType, config) as Prisma.InputJsonValue;
await this.prisma.iiosProviderCredential.upsert({
where: { scopeId_providerType: { scopeId, providerType } },
create: { scopeId, providerType, ...sealed, displayHints, enabled },
update: { ...sealed, displayHints, enabled },
});
}
/** Decrypt the config for send-time use. Null when unconfigured, disabled, or no key. */
async resolve(scopeId: string, providerType: string): Promise<ProviderConfig | null> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row || !row.enabled) return null;
const key = credKeyFromEnv(this.env);
if (!key) return null;
const sealed: SealedSecret = { cipherText: row.cipherText, iv: row.iv, authTag: row.authTag, keyVersion: row.keyVersion };
return JSON.parse(openSecret(sealed, key)) as ProviderConfig;
}
/** Masked view for the admin UI — configured + hints, never the secret. */
async status(scopeId: string, providerType: string): Promise<CredentialStatus> {
const row = await this.prisma.iiosProviderCredential.findUnique({
where: { scopeId_providerType: { scopeId, providerType } },
});
if (!row) return { configured: false };
return { configured: true, enabled: row.enabled, hints: (row.displayHints ?? {}) as Record<string, unknown> };
}
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { credKeyFromEnv, sealSecret, openSecret, SECRET_KEY_VERSION } from './secret-crypto';
const KEY = Buffer.alloc(32, 7); // deterministic 32-byte key for tests
describe('secret-crypto', () => {
it('round-trips a secret through seal → open', () => {
const sealed = sealSecret('sk_live_abc123', KEY);
expect(sealed.keyVersion).toBe(SECRET_KEY_VERSION);
expect(sealed.cipherText).not.toContain('sk_live_abc123');
expect(openSecret(sealed, KEY)).toBe('sk_live_abc123');
});
it('produces a distinct ciphertext each time (random IV)', () => {
const a = sealSecret('same', KEY);
const b = sealSecret('same', KEY);
expect(a.cipherText).not.toBe(b.cipherText);
expect(a.iv).not.toBe(b.iv);
});
it('fails to open with the wrong key', () => {
const sealed = sealSecret('top-secret', KEY);
expect(() => openSecret(sealed, Buffer.alloc(32, 9))).toThrow();
});
it('fails to open if the ciphertext is tampered (GCM auth)', () => {
const sealed = sealSecret('top-secret', KEY);
const bad = { ...sealed, cipherText: Buffer.from('deadbeef', 'hex').toString('base64') };
expect(() => openSecret(bad, KEY)).toThrow();
});
it('credKeyFromEnv returns null when unset and rejects a wrong-length key', () => {
expect(credKeyFromEnv({})).toBeNull();
expect(() => credKeyFromEnv({ IIOS_CRED_KEY: Buffer.alloc(16).toString('base64') })).toThrow(/32 bytes/);
const key = credKeyFromEnv({ IIOS_CRED_KEY: KEY.toString('base64') });
expect(key?.length).toBe(32);
});
});
@@ -0,0 +1,46 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
/**
* Envelope for a tenant secret sealed with AES-256-GCM. Stored as base64 columns on
* IiosProviderCredential; the plaintext (a provider config JSON string) never touches disk.
*/
export interface SealedSecret {
cipherText: string;
iv: string;
authTag: string;
keyVersion: number;
}
/** Bumped only when the platform master key rotates; lets old rows decrypt under an old key. */
export const SECRET_KEY_VERSION = 1;
/**
* Resolve the 32-byte platform master key from `IIOS_CRED_KEY` (base64). Returns null when
* unset (so egress providers that need it stay unregistered / fail closed) but THROWS on a
* present-but-malformed key a wrong-length key is an operator error, not a "disabled" state.
*/
export function credKeyFromEnv(env: NodeJS.ProcessEnv = process.env): Buffer | null {
const raw = env.IIOS_CRED_KEY;
if (!raw) return null;
const key = Buffer.from(raw, 'base64');
if (key.length !== 32) throw new Error('IIOS_CRED_KEY must decode to 32 bytes (base64-encoded 256-bit key)');
return key;
}
export function sealSecret(plaintext: string, key: Buffer): SealedSecret {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return {
cipherText: enc.toString('base64'),
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
keyVersion: SECRET_KEY_VERSION,
};
}
export function openSecret(sealed: SealedSecret, key: Buffer): string {
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(sealed.iv, 'base64'));
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(sealed.cipherText, 'base64')), decipher.final()]).toString('utf8');
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest';
import type { CapabilityRequest } from '@insignia/iios-contracts';
import { TwilioSmsProvider, type TwilioCreds } from './twilio-sms.provider';
const CREDS: TwilioCreds = { accountSid: 'AC123', authToken: 'tok_secret', fromNumber: '+15550001111' };
const req = (over: Partial<CapabilityRequest> = {}): CapabilityRequest => ({
capability: 'channel.send',
channelType: 'SMS',
scopeId: 'scope_1',
target: '+15557654321',
payload: { text: 'hello world' },
idempotencyKey: 'idem-1',
...over,
});
describe('TwilioSmsProvider', () => {
it('resolves the scope creds and POSTs to Twilio, returning SENT with the message SID', async () => {
const http = vi.fn().mockResolvedValue({ ok: true, status: 201, json: async () => ({ sid: 'SM999' }), text: async () => '' });
const resolve = vi.fn().mockResolvedValue(CREDS);
const p = new TwilioSmsProvider(resolve, http);
const res = await p.send(req());
expect(resolve).toHaveBeenCalledWith('scope_1');
const [url, init] = http.mock.calls[0];
expect(url).toBe('https://api.twilio.com/2010-04-01/Accounts/AC123/Messages.json');
expect(init.method).toBe('POST');
expect(init.headers.authorization).toBe(`Basic ${Buffer.from('AC123:tok_secret').toString('base64')}`);
const body = new URLSearchParams(init.body as string);
expect(body.get('To')).toBe('+15557654321');
expect(body.get('From')).toBe('+15550001111');
expect(body.get('Body')).toBe('hello world');
expect(res).toMatchObject({ outcome: 'SENT', providerRef: 'SM999' });
});
it('fails closed as NOT_CONFIGURED when the scope has no creds', async () => {
const http = vi.fn();
const p = new TwilioSmsProvider(async () => null, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
expect(http).not.toHaveBeenCalled();
});
it('fails closed as NOT_CONFIGURED when the request has no scope', async () => {
const p = new TwilioSmsProvider(async () => CREDS, vi.fn());
const res = await p.send(req({ scopeId: undefined }));
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('NOT_CONFIGURED');
});
it('surfaces a Twilio API error as FAILED (never throws)', async () => {
const http = vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({ code: 20003, message: 'Authenticate' }), text: async () => 'Authenticate' });
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
expect(res.errorCode).toBe('TWILIO_20003');
});
it('surfaces a transport throw as FAILED', async () => {
const http = vi.fn().mockRejectedValue(new Error('network down'));
const p = new TwilioSmsProvider(async () => CREDS, http);
const res = await p.send(req());
expect(res.outcome).toBe('FAILED');
});
});
@@ -0,0 +1,70 @@
import type { CapabilityProvider, CapabilityRequest, ProviderResult } from '@insignia/iios-contracts';
/** The decrypted Twilio config a tenant configures (BYO credentials). */
export interface TwilioCreds {
accountSid: string;
authToken: string;
fromNumber: string;
}
/** Resolves a scope's Twilio creds (decrypting on demand); null when unconfigured/disabled. */
export type TwilioCredResolver = (scopeId: string) => Promise<TwilioCreds | null>;
/** Injectable HTTP sender so tests don't hit the network; prod uses fetch. */
export type HttpSender = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
}>;
const defaultSender: HttpSender = (url, init) => fetch(url, init) as unknown as ReturnType<HttpSender>;
/**
* SMS egress via Twilio, using the CALLER'S OWN credentials (resolved per scope at send time).
* Fail-closed: no scope or no configured creds FAILED/NOT_CONFIGURED, nothing sent. A Twilio
* API error is surfaced as FAILED (never thrown), per the provider doctrine.
*/
export class TwilioSmsProvider implements CapabilityProvider {
readonly name = 'twilio-sms';
readonly channelTypes = ['SMS'];
readonly capabilities = { canSend: true, maxPayloadBytes: 1600 };
constructor(
private readonly resolve: TwilioCredResolver,
private readonly http: HttpSender = defaultSender,
) {}
async send(req: CapabilityRequest): Promise<ProviderResult> {
const started = Date.now();
if (!req.scopeId) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const creds = await this.resolve(req.scopeId).catch(() => null);
if (!creds) return { providerRef: 'unconfigured', outcome: 'FAILED', errorCode: 'NOT_CONFIGURED' };
const body = new URLSearchParams({
To: req.target,
From: creds.fromNumber,
Body: String(req.payload.text ?? req.payload.body ?? ''),
}).toString();
try {
const res = await this.http(`https://api.twilio.com/2010-04-01/Accounts/${creds.accountSid}/Messages.json`, {
method: 'POST',
headers: {
authorization: `Basic ${Buffer.from(`${creds.accountSid}:${creds.authToken}`).toString('base64')}`,
'content-type': 'application/x-www-form-urlencoded',
},
body,
});
const latencyMs = Date.now() - started;
const payload = (await res.json().catch(() => ({}))) as { sid?: string; code?: number; message?: string };
if (!res.ok) {
return { providerRef: `twilio-${res.status}`, outcome: 'FAILED', errorCode: payload.code ? `TWILIO_${payload.code}` : `HTTP_${res.status}`, latencyMs };
}
return { providerRef: payload.sid ?? 'twilio-sent', outcome: 'SENT', latencyMs };
} catch (err) {
return { providerRef: 'twilio-error', outcome: 'FAILED', errorCode: (err as Error).message.slice(0, 60), latencyMs: Date.now() - started };
}
}
}
@@ -114,10 +114,18 @@ export class MessageService {
const actor = await this.actors.resolveActor(thread.scopeId, principal);
const alreadyMember =
(await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } })) !== null;
// Join is governed ONLY for threads that opted into a membership model (chat dm/group);
// support/inbox/generic threads (no membership attr) keep open-join, unchanged.
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, { action: 'iios.thread.join', threadId, scopeId: thread.scopeId, membership, alreadyMember });
// Join is governed ONLY for threads that opted into a membership model (chat dm/group/channel);
// support/inbox/generic threads (no membership attr) keep open-join, unchanged. A PUBLIC channel
// is the one membership type that also allows open self-join (policy reads `visibility`).
const bag = (thread.metadata as { membership?: string; visibility?: string } | null) ?? {};
await decideOrThrow(this.ports, {
action: 'iios.thread.join',
threadId,
scopeId: thread.scopeId,
membership: bag.membership,
visibility: bag.visibility,
alreadyMember,
});
await this.actors.ensureParticipant(threadId, actor.id);
return { threadId, status: thread.status, history: await this.history(threadId) };
}
@@ -228,6 +236,60 @@ export class MessageService {
}));
}
/**
* Scope-wide thread discovery the ONE generic primitive channels need beyond dm/group.
* Unlike listThreads (membership-scoped), this returns threads in the caller's scope matching an
* opaque metadata filter (e.g. {membership:'channel', visibility:'public'}) REGARDLESS of whether
* the caller is a participant, each flagged `joined`. Governed by policy (fail-closed) + fenced to
* the caller's own scope. The kernel never interprets the filter keys.
*/
async discoverThreads(
principal: MessagePrincipal,
filter?: { metadata?: Record<string, string> },
): Promise<Array<{ threadId: string; subject: string | null; metadata: Record<string, unknown> | null; participantCount: number; joined: boolean }>> {
const scope = await this.actors.findScope(principal);
if (!scope) return [];
await decideOrThrow(this.ports, { action: 'iios.thread.discover', scopeId: scope.id });
const actor = await this.actors.resolveActor(scope.id, principal);
const inScope = await this.prisma.iiosThread.findMany({ where: { scopeId: scope.id } });
const metaFilter = filter?.metadata;
const matched = metaFilter
? inScope.filter((t) => {
const bag = (t.metadata as Record<string, unknown> | null) ?? {};
return Object.entries(metaFilter).every(([k, v]) => bag[k] === v);
})
: inScope;
if (matched.length === 0) return [];
const ids = matched.map((t) => t.id);
const parts = await this.prisma.iiosThreadParticipant.groupBy({ by: ['threadId'], where: { threadId: { in: ids } }, _count: { actorId: true } });
const countBy = new Map(parts.map((p) => [p.threadId, p._count.actorId]));
const mine = await this.prisma.iiosThreadParticipant.findMany({ where: { threadId: { in: ids }, actorId: actor.id }, select: { threadId: true } });
const joinedSet = new Set(mine.map((m) => m.threadId));
return matched.map((t) => ({
threadId: t.id,
subject: t.subject,
metadata: (t.metadata as Record<string, unknown> | null) ?? null,
participantCount: countBy.get(t.id) ?? 0,
joined: joinedSet.has(t.id),
}));
}
/** Self-leave: remove the caller's own participant row. Governed (a member may always leave). */
async leaveThread(threadId: string, principal: MessagePrincipal): Promise<{ threadId: string; participantCount: number }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
if (!thread) throw new NotFoundException('thread not found');
const actor = await this.actors.resolveActor(thread.scopeId, principal);
const callerP = await this.prisma.iiosThreadParticipant.findUnique({ where: { threadId_actorId: { threadId, actorId: actor.id } } });
const membership = (thread.metadata as { membership?: string } | null)?.membership;
await decideOrThrow(this.ports, { action: 'iios.thread.leave', threadId, scopeId: thread.scopeId, membership, callerRole: callerP?.participantRole });
await this.prisma.iiosThreadParticipant.deleteMany({ where: { threadId, actorId: actor.id } });
const participantCount = await this.prisma.iiosThreadParticipant.count({ where: { threadId } });
return { threadId, participantCount };
}
/** Toggle the caller's per-thread notification mute flag. */
async muteThread(threadId: string, principal: MessagePrincipal, muted: boolean): Promise<{ threadId: string; muted: boolean }> {
const thread = await this.prisma.iiosThread.findUnique({ where: { id: threadId } });
@@ -154,6 +154,34 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect((await s.openThread(threadId, bob)).threadId).toBe(threadId);
});
it('channels: a PUBLIC channel allows open self-join; a PRIVATE one does not', async () => {
const s = gov();
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
expect((await s.openThread(pub, bob)).threadId).toBe(pub); // bob self-joins a public channel
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
await expect(s.openThread(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError); // private = invite-only
});
it('discoverThreads browses same-scope channels with a joined flag; leave removes me', async () => {
const s = gov();
const { threadId: gen } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' }); // a group must NOT appear in a channel browse
// bob (non-member, same scope) discovers the public channel as not-joined.
const seen = await s.discoverThreads(bob, { metadata: { membership: 'channel', visibility: 'public' } });
expect(seen.map((t) => t.threadId)).toEqual([gen]);
expect(seen[0]!.joined).toBe(false);
// alice (member) sees it joined.
expect((await s.discoverThreads(alice, { metadata: { membership: 'channel', visibility: 'public' } }))[0]!.joined).toBe(true);
// bob joins, appears in his list, then leaves.
await s.openThread(gen, bob);
expect((await s.listThreads(bob)).map((t) => t.threadId)).toContain(gen);
await s.leaveThread(gen, bob);
expect((await s.listThreads(bob)).map((t) => t.threadId)).not.toContain(gen);
});
it('listThreads returns the callers threads with membership, count, last message + unread', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
@@ -62,6 +62,20 @@ describe('DevOpaPort (dev policy plane — membership rules)', () => {
expect(big.obligations[0]?.reason).toMatch(/too large/);
});
it('a PUBLIC channel allows open self-join; private channel and group do not', async () => {
expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'public', alreadyMember: false })).allow).toBe(true);
const priv = await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: false });
expect(priv.allow).toBe(false);
expect((await opa.decide({ action: 'iios.thread.join', membership: 'group', alreadyMember: false })).allow).toBe(false);
// an existing member of a private channel can still (re)open it
expect((await opa.decide({ action: 'iios.thread.join', membership: 'channel', visibility: 'private', alreadyMember: true })).allow).toBe(true);
});
it('discover and leave are allowed (scope fence / member-implied)', async () => {
expect((await opa.decide({ action: 'iios.thread.discover' })).allow).toBe(true);
expect((await opa.decide({ action: 'iios.thread.leave', membership: 'channel' })).allow).toBe(true);
});
it('self-join is governed only on membership threads', async () => {
// generic / support thread (no membership attr) → open join, unchanged
expect((await opa.decide({ action: 'iios.thread.join', alreadyMember: false })).allow).toBe(true);
@@ -9,7 +9,8 @@ import type { PolicyDecision } from '@insignia/iios-contracts';
*/
export interface OpaInput {
action?: string;
membership?: string; // app-set generic thread attribute: 'dm' | 'group'
membership?: string; // app-set generic thread attribute: 'dm' | 'group' | 'channel'
visibility?: string; // 'public' | 'private' (channels)
participantCount?: number;
callerRole?: string; // 'MEMBER' | 'ADMIN'
alreadyMember?: boolean;
@@ -55,12 +56,21 @@ export class DevOpaPort {
return allow();
}
case 'iios.thread.join': {
// Only threads that opted into a membership model (chat dm/group) are governed;
// generic/support threads (no membership attr) keep open-join. For a governed
// thread, new members must be added first (governed add-participant).
// Generic/support threads (no membership attr) keep open-join. A PUBLIC channel is the one
// membership type that also allows open self-join. dm/group/private-channel are closed:
// new members must be added first (governed add-participant).
if (!i.membership) return allow();
if (i.membership === 'channel' && i.visibility === 'public') return allow();
return i.alreadyMember ? allow() : deny('you are not a member of this thread');
}
case 'iios.thread.discover': {
// Browsing discoverable threads — the query fences to the caller's own scope, so allow.
return allow();
}
case 'iios.thread.leave': {
// Anyone may leave a thread they're in. (A membership thread implies the caller is a member.)
return allow();
}
case 'iios.interaction.annotate': {
// Annotating (e.g. reacting to) a message requires being a participant of its thread.
return i.isMember ? allow() : deny('you are not a member of this thread');
@@ -44,6 +44,18 @@ export class ThreadsController {
return this.messages.listMyAnnotated(this.principal(auth), type);
}
/**
* Discover threads across the caller's scope (not just ones they're in) matching an opaque
* metadata filter e.g. `?metadata[membership]=channel&metadata[visibility]=public` to browse
* public channels. Each result is flagged `joined`. Declared before `:id` routes so the static
* "discover" segment wins.
*/
@Get('discover')
async discover(@Headers('authorization') auth?: string, @Query('metadata') metadata?: Record<string, string>) {
const metaFilter = metadata && typeof metadata === 'object' ? metadata : undefined;
return this.messages.discoverThreads(this.principal(auth), metaFilter ? { metadata: metaFilter } : undefined);
}
/** Create a thread; `membership`/`creatorRole`/`subject`/`metadata` are opaque, app-supplied attributes the kernel stores but never interprets. */
@Post()
@HttpCode(201)
@@ -79,6 +91,13 @@ export class ThreadsController {
return this.messages.renameThread(id, this.principal(auth), body.subject);
}
/** Self-leave: remove the caller from a thread (e.g. leave a channel). */
@Delete(':id/me')
@HttpCode(200)
async leave(@Param('id') id: string, @Headers('authorization') auth?: string) {
return this.messages.leaveThread(id, this.principal(auth));
}
@Get(':id/messages')
async listMessages(@Param('id') id: string) {
return this.threads.getMessages(id);
+404 -2
View File
@@ -16,7 +16,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0)
version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)
apps/adapter-inspector:
dependencies:
@@ -326,6 +326,42 @@ importers:
specifier: ^5.7.3
version: 5.9.3
packages/iios-messaging-ui:
devDependencies:
'@insignia/iios-kernel-client':
specifier: workspace:*
version: link:../iios-kernel-client
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.1
'@testing-library/react':
specifier: ^16.1.0
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@types/react':
specifier: ^19.0.0
version: 19.2.17
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.3(@types/react@19.2.17)
jsdom:
specifier: ^26.0.0
version: 26.1.0
react:
specifier: ^19.0.0
version: 19.2.7
react-dom:
specifier: ^19.0.0
version: 19.2.7(react@19.2.7)
tsup:
specifier: ^8.3.5
version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.0.5
version: 3.2.6(@types/node@26.0.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)
packages/iios-service:
dependencies:
'@aws-sdk/client-s3':
@@ -490,6 +526,9 @@ packages:
resolution: {integrity: sha512-/PZmyAlb2NGWPikRRuiWLdfHQd8Wrx6lX4HqvTcaDhlU43M3T0ud4PH2T3QDp7BzHYY92xtD8iPxX2asg67G1A==}
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
'@aws-sdk/checksums@3.1000.18':
resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==}
engines: {node: '>=20.0.0'}
@@ -633,6 +672,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
engines: {node: '>=6.9.0'}
@@ -652,6 +695,34 @@ packages:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
'@csstools/css-calc@2.1.4':
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-color-parser@3.1.0':
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-parser-algorithms@3.0.5':
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-tokenizer@3.0.4':
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -1561,6 +1632,25 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@tokenizer/inflate@0.4.1':
resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
engines: {node: '>=18'}
@@ -1568,6 +1658,9 @@ packages:
'@tokenizer/token@0.3.0':
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1813,6 +1906,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
ansis@4.2.0:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'}
@@ -1826,6 +1923,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
array-timsort@1.0.3:
resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==}
@@ -2060,9 +2160,17 @@ packages:
typescript:
optional: true
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
debug@4.3.7:
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
engines: {node: '>=6.0'}
@@ -2081,6 +2189,9 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -2107,9 +2218,16 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -2156,6 +2274,10 @@ packages:
resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==}
engines: {node: '>=10.13.0'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -2355,10 +2477,18 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http-proxy-agent@7.0.2:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
http_ece@1.2.0:
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
engines: {node: '>=16'}
@@ -2367,6 +2497,10 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
@@ -2400,6 +2534,9 @@ packages:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -2436,6 +2573,15 @@ packages:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
jsdom@26.1.0:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -2534,6 +2680,9 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.5.1:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
@@ -2544,6 +2693,10 @@ packages:
lru-memoizer@3.0.0:
resolution: {integrity: sha512-m83w/cYXLdUIboKSPxzPAGfYnk+vqeDYXuoSrQRw1q+yVEd8IXhvMufN8Q5TIPe7e2jyX4SRNrDJI2Skw1yznQ==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -2663,6 +2816,9 @@ packages:
notepack.io@3.0.1:
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
nwsapi@2.2.24:
resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
nypm@0.6.8:
resolution: {integrity: sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==}
engines: {node: '>=18'}
@@ -2706,6 +2862,9 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -2774,6 +2933,10 @@ packages:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prisma@6.19.3:
resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==}
engines: {node: '>=18.18'}
@@ -2815,6 +2978,9 @@ packages:
peerDependencies:
react: ^19.2.7
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -2867,6 +3033,9 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
rxjs@7.8.1:
resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
@@ -2879,6 +3048,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -3026,6 +3199,9 @@ packages:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tapable@2.3.3:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
@@ -3111,6 +3287,13 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
tldts@6.1.86:
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
hasBin: true
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
@@ -3119,6 +3302,14 @@ packages:
resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==}
engines: {node: '>=14.16'}
tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
engines: {node: '>=16'}
tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -3336,6 +3527,10 @@ packages:
jsdom:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
@@ -3348,6 +3543,10 @@ packages:
engines: {node: '>= 16'}
hasBin: true
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webpack-node-externals@3.0.0:
resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==}
engines: {node: '>=6'}
@@ -3366,6 +3565,19 @@ packages:
webpack-cli:
optional: true
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
whatwg-mimetype@4.0.0:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@14.2.0:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
@@ -3393,6 +3605,13 @@ packages:
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xmlhttprequest-ssl@2.1.2:
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
engines: {node: '>=0.4.0'}
@@ -3464,6 +3683,14 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
'@aws-sdk/checksums@3.1000.18':
dependencies:
'@aws-sdk/core': 3.975.3
@@ -3718,6 +3945,8 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/runtime@7.29.7': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
@@ -3746,6 +3975,26 @@ snapshots:
'@colors/colors@1.5.0':
optional: true
'@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/color-helpers': 5.1.0
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-tokenizer@3.0.4': {}
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -4418,6 +4667,27 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3
@@ -4427,6 +4697,8 @@ snapshots:
'@tokenizer/token@0.3.0': {}
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.7
@@ -4741,6 +5013,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansis@4.2.0: {}
any-promise@1.3.0: {}
@@ -4749,6 +5023,10 @@ snapshots:
argparse@2.0.1: {}
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
array-timsort@1.0.3: {}
asn1.js@5.4.1:
@@ -4973,8 +5251,18 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
cssstyle@4.6.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
rrweb-cssom: 0.8.0
csstype@3.2.3: {}
data-urls@5.0.0:
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
debug@4.3.7:
dependencies:
ms: 2.1.3
@@ -4983,6 +5271,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
deep-eql@5.0.2: {}
deepmerge-ts@7.1.5: {}
@@ -4999,8 +5289,12 @@ snapshots:
depd@2.0.0: {}
dequal@2.0.3: {}
destr@2.0.5: {}
dom-accessibility-api@0.5.16: {}
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -5064,6 +5358,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@6.0.1: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -5365,6 +5661,10 @@ snapshots:
dependencies:
function-bind: 1.1.2
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -5373,6 +5673,13 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
http_ece@1.2.0: {}
https-proxy-agent@7.0.6:
@@ -5382,6 +5689,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
@@ -5415,6 +5726,8 @@ snapshots:
is-interactive@1.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {}
is-unicode-supported@0.1.0: {}
@@ -5441,6 +5754,33 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@26.1.0:
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.24
parse5: 7.3.0
rrweb-cssom: 0.8.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.21.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
jsesc@3.1.0: {}
json-parse-even-better-errors@2.3.1: {}
@@ -5533,6 +5873,8 @@ snapshots:
loupe@3.2.1: {}
lru-cache@10.4.3: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1:
@@ -5544,6 +5886,8 @@ snapshots:
lodash.clonedeep: 4.5.0
lru-cache: 11.5.1
lz-string@1.5.0: {}
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5640,6 +5984,8 @@ snapshots:
notepack.io@3.0.1: {}
nwsapi@2.2.24: {}
nypm@0.6.8:
dependencies:
citty: 0.2.2
@@ -5689,6 +6035,10 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse5@7.3.0:
dependencies:
entities: 6.0.1
parseurl@1.3.3: {}
path-scurry@2.0.2:
@@ -5739,6 +6089,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
prisma@6.19.3(typescript@5.9.3):
dependencies:
'@prisma/config': 6.19.3
@@ -5781,6 +6137,8 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
react-is@17.0.2: {}
react-refresh@0.17.0: {}
react@19.2.7: {}
@@ -5853,6 +6211,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrweb-cssom@0.8.0: {}
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
@@ -5865,6 +6225,10 @@ snapshots:
safer-buffer@2.1.2: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
schema-utils@3.3.0:
@@ -6053,6 +6417,8 @@ snapshots:
symbol-observable@4.0.0: {}
symbol-tree@3.2.4: {}
tapable@2.3.3: {}
terser-webpack-plugin@5.6.1(webpack@5.106.2):
@@ -6095,6 +6461,12 @@ snapshots:
tinyspy@4.0.4: {}
tldts-core@6.1.86: {}
tldts@6.1.86:
dependencies:
tldts-core: 6.1.86
toidentifier@1.0.1: {}
token-types@6.1.2:
@@ -6103,6 +6475,14 @@ snapshots:
'@tokenizer/token': 0.3.0
ieee754: 1.2.1
tough-cookie@5.1.2:
dependencies:
tldts: 6.1.86
tr46@5.1.1:
dependencies:
punycode: 2.3.1
tree-kill@1.2.2: {}
ts-interface-checker@0.1.13: {}
@@ -6249,7 +6629,7 @@ snapshots:
jiti: 2.7.0
terser: 5.48.0
vitest@3.2.6(@types/node@26.0.1)(jiti@2.7.0)(terser@5.48.0):
vitest@3.2.6(@types/node@26.0.1)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
@@ -6276,6 +6656,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.0.1
jsdom: 26.1.0
transitivePeerDependencies:
- jiti
- less
@@ -6290,6 +6671,10 @@ snapshots:
- tsx
- yaml
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
watchpack@2.5.2:
dependencies:
graceful-fs: 4.2.11
@@ -6308,6 +6693,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
webidl-conversions@7.0.0: {}
webpack-node-externals@3.0.0: {}
webpack-sources@3.5.0: {}
@@ -6352,6 +6739,17 @@ snapshots:
- postcss
- uglify-js
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
whatwg-mimetype@4.0.0: {}
whatwg-url@14.2.0:
dependencies:
tr46: 5.1.1
webidl-conversions: 7.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
@@ -6369,6 +6767,10 @@ snapshots:
ws@8.21.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xmlhttprequest-ssl@2.1.2: {}
yallist@3.1.1: {}
+4
View File
@@ -20,6 +20,10 @@ const ALLOWED = {
'@insignia/iios-adapter-sdk': ['@insignia/iios-contracts'],
'@insignia/iios-service': ['@insignia/iios-contracts', '@insignia/iios-testkit', '@insignia/iios-adapter-sdk'],
'@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
// Core is transport-free; only ./adapters/kernel may import the kernel client.
// This map is package-level and cannot express that subpath rule — the finer
// constraint is enforced by packages/iios-messaging-ui/src/boundary.test.ts.
'@insignia/iios-messaging-ui': ['@insignia/iios-kernel-client'],
'@insignia/iios-inbox-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
'@insignia/iios-community-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
'@insignia/iios-ai-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],