17 Commits

Author SHA1 Message Date
maaz519 ade1d68015 feat: channels on the direct-IIOS path — kernel-client + KernelClientAdapter
- iios-kernel-client: RestClient.discoverThreads (GET /v1/threads/discover) +
  leaveThread (DELETE /v1/threads/:id/me); DiscoveredThread type
- KernelClientAdapter implements the four channel methods: browseChannels maps
  discovered public channels → ChannelSummary; createChannel → createThread
  (membership=channel, ADMIN, visibility/topic metadata); joinChannel →
  socket.openThread (governed public self-join); leaveChannel → rest.leaveThread
- adapter channel test over the fake transport (13 kernel-adapter tests); 57 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 ebf553eb68 feat(threads): channel backend — discover (browse), public self-join, leave
The generic primitives channels need beyond dm/group, kept policy-governed and
scope-fenced (kernel never interprets 'channel'/'public'):

- discoverThreads(principal, filter): scope-wide browse (NOT membership-scoped)
  matching an opaque metadata filter, each result flagged joined; governed by
  iios.thread.discover (scope fence in the query). REST: GET /v1/threads/discover
- open self-join for PUBLIC channels: openThread now passes visibility into the
  join decision; dev-OPA iios.thread.join allows membership=channel+visibility=
  public (dm/group/private-channel stay invite-only)
- leaveThread + iios.thread.leave + REST DELETE /v1/threads/:id/me
- tests: public self-join vs private denied, discover joined-flags + group
  excluded + leave; dev-opa join-public/discover/leave rules (29 green)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 cb9a0b9250 feat(messaging-ui): @mentions — autocomplete, resolve to ids, highlight
- contract: SendOpts.mentions (opaque userId notify-list) + optional
  listMembers(threadId) for autocomplete/highlight (capability-degrading)
- mock: listMembers; KernelClientAdapter.send forwards mentions to the socket
  (which already carries them → inbox MENTION items)
- composer: type @ → member/@channel/@here autocomplete; Enter picks the first;
  on send, mentions resolve to ids; message bodies highlight @mentions
- useMembers hook; mentions.tsx helpers (trailingMentionQuery/insertMention/
  resolveMentions/highlightMentions)
- 4 mention tests (helpers + render autocomplete→send carries id); 56 total green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 00a2cc474a feat(messaging-ui): channels — browse, join, leave, create (contract + mock + UI)
Adds a third membership beyond dm/group: a discoverable, joinable room.

- contract: Membership += 'channel'; Conversation.topic; ChannelSummary +
  CreateChannelInput; optional adapter methods browseChannels/createChannel/
  joinChannel/leaveChannel (capability-degrading — absent hides the UI)
- MockAdapter implements them (seeds a joined public, a joinable public, and a
  private channel); listConversations now returns only threads I'm in
- useChannels hook (browse/create/join/leave + supported flag)
- UI: sidebar sections (Channels vs Direct messages), a + that opens a
  ChannelBrowser (join + create), # / lock glyphs; themeable styles
- KernelClientAdapter passes channel membership + topic through
- 4 channel tests (mock browse/join/create + render browse→join); 52 total green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 3f1f89dfbe feat(messaging-ui): rendered Messenger components — the drop-in UI
Turns the SDK from hooks-only into a real UI SDK: <Messenger> (conversation
list + open thread + composer), plus <ConversationList> and <Thread>, all
driven by the existing useConversations/useMessages hooks over the injected
adapter — zero transport imports (boundary intact).

- themeable via --miu-* CSS variables; default theme ships as ./styles.css
- optimistic send, typing indicator, seen ticks, reactions display, unread badges
- render smoke tests (jsdom + MockAdapter): mounts, auto-selects first thread,
  sends a message, switches threads (3 tests) — 48 total green
- tsup: css bundled to dist/styles.css; dts scoped to TS entries

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 c5237a237a feat(messaging-ui): KernelClientAdapter — direct-IIOS transport for the SDK
Implements MessagingAdapter over @insignia/iios-kernel-client (browser → IIOS,
token-in): listConversations/openThread/history/send/subscribe/typing/markRead
/react, mapping kernel Message/ThreadSummary/annotations onto the SDK's neutral
types. currentActorId comes from the host's session (senderId space), never
inferred from history — the exact bug the conformance suite kills.

- lives in adapters/ (transport boundary intact — core stays transport-free)
- ships from the ./adapters/kernel-client subpath (kernel-client is an optional
  peer dep; excluded from the main bundle)
- connectKernelAdapter({serviceUrl, token, currentUserId}) convenience
- passes the shared adapter conformance suite (12 tests) over the real
  MessageSocket facade with only the socket.io layer faked
- media/attachments deferred (kernel-client has no presign yet)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 191c9748c5 docs: messaging-ui foundation implementation plan (plan 1 of 3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 778e98134c feat: export messaging-ui public API and enforce transport boundary
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 99f9ac84fb feat: add useMessages hook with explicit ownership and optimistic send
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 0ffe21a0c2 feat: add useConversations hook 2026-07-23 00:15:20 +05:30
maaz519 256a5dcea0 feat: add MessagingProvider and useAdapter 2026-07-23 00:15:20 +05:30
maaz519 54f426e4f0 feat: add MockAdapter passing the conformance suite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:15:20 +05:30
maaz519 0273d1c70f feat: add adapter conformance suite 2026-07-23 00:15:20 +05:30
maaz519 3e9951b3a8 feat: define MessagingAdapter contract 2026-07-23 00:15:20 +05:30
maaz519 0c9b27684b feat: add messaging-ui domain types 2026-07-23 00:15:20 +05:30
maaz519 167171a682 chore: scaffold @insignia/iios-messaging-ui with jsdom test setup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 00:15:17 +05:30
maaz519 00d22be714 Merge pull request 'feat(media): allow HTML/Markdown/CSV uploads; serve scriptable types as downloads' (#5) from feat/s3-storage into dev
Reviewed-on: #5
2026-07-20 09:01:56 +00:00
44 changed files with 4893 additions and 11 deletions
File diff suppressed because it is too large Load Diff
+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;
+59
View File
@@ -0,0 +1,59 @@
{
"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"
},
"./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": {
"react": ">=18",
"@insignia/iios-kernel-client": "*"
},
"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",
"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/"
}
}
+79
View File
@@ -0,0 +1,79 @@
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[]>;
// ── 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,239 @@
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 openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
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,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,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,81 @@
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 { Thread } from './thread';
/**
* 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 [selected, setSelected] = useState<string | null>(null);
const [browsing, setBrowsing] = useState(false);
useEffect(() => {
if (browsing) return;
if (selected && conversations.some((c) => c.threadId === selected)) return;
setSelected(conversations[0]?.threadId ?? null);
}, [conversations, selected, browsing]);
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);
setSelected(threadId);
}
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={() => setBrowsing(true)}>
</button>
</div>
{channels.length > 0 ? (
<ConversationList conversations={channels} selectedId={browsing ? null : selected} onSelect={pick} />
) : (
<div className="miu-empty miu-empty-sm">Browse to join a channel.</div>
)}
</div>
) : null}
<div className="miu-section">
{channelsSupported ? (
<div className="miu-section-head">
<span>Direct messages</span>
</div>
) : null}
<ConversationList conversations={dms} selectedId={browsing ? null : selected} onSelect={pick} />
</div>
</aside>
<section className="miu-main">
{browsing ? (
<ChannelBrowser
onJoined={(threadId) => {
refetch();
pick(threadId);
}}
/>
) : (
<Thread threadId={selected} />
)}
</section>
</div>
);
}
@@ -0,0 +1,124 @@
import { useMemo, useState, type FormEvent, type KeyboardEvent } from 'react';
import { useMessages } from '../hooks/use-messages';
import { useMembers } from '../hooks/use-members';
import {
SPECIAL_MENTIONS,
highlightMentions,
insertMention,
resolveMentions,
trailingMentionQuery,
} from '../mentions';
interface Suggestion {
key: string;
label: string;
insert: string;
}
/**
* One conversation: message list + composer, driven by useMessages. Adds @mention autocomplete
* (from the thread's members) and highlights mentions in message bodies. Transport-agnostic.
*/
export function Thread({ threadId }: { threadId: string | null }) {
const { messages, loading, error, send, typingUserIds, seenIds, sendTyping } = useMessages(threadId);
const members = useMembers(threadId);
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const memberNames = useMemo(() => members.map((m) => m.name), [members]);
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 submit(e?: FormEvent): Promise<void> {
e?.preventDefault();
const text = draft.trim();
if (!text || sending) return;
const mentions = resolveMentions(text, members);
setDraft('');
setSending(true);
try {
await send(text, mentions.length ? { mentions } : undefined);
} catch {
// surfaced via the hook's error
} finally {
setSending(false);
}
}
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
if (showSuggest && e.key === 'Enter') {
e.preventDefault();
pick(suggestions[0]!.insert);
}
}
if (!threadId) {
return <div className="miu-empty miu-thread-empty">Select a conversation.</div>;
}
return (
<div className="miu-thread">
<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}
{messages.map((m) => (
<div key={m.id} className={`miu-msg${m.mine ? ' is-mine' : ''}${m.pending ? ' is-pending' : ''}`}>
<div className="miu-bubble">{highlightMentions(m.text, memberNames)}</div>
{m.reactions && m.reactions.length > 0 ? (
<div className="miu-reactions">
{m.reactions.map((r) => (
<span key={r.emoji} className={`miu-reaction${r.mine ? ' is-mine' : ''}`}>
{r.emoji} {r.count}
</span>
))}
</div>
) : null}
{m.mine && seenIds.has(m.id) ? <span className="miu-seen">Seen</span> : null}
</div>
))}
{typingUserIds.length > 0 ? (
<div className="miu-typing">{typingUserIds.length === 1 ? 'typing…' : 'several people are typing…'}</div>
) : null}
</div>
<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}
<input
className="miu-input"
value={draft}
placeholder="Type a message… @ to mention"
aria-label="Message"
onChange={(e) => {
setDraft(e.target.value);
sendTyping();
}}
onKeyDown={onKeyDown}
/>
<button type="submit" className="miu-send" disabled={!draft.trim() || sending}>
Send
</button>
</form>
</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,205 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAdapter } from '../provider';
import { isOwnMessage } from '../types';
import type { 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>;
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 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,
typingUserIds,
seenIds: seenMine,
sendTyping,
canReact: typeof adapter.react === 'function',
canUpload: typeof adapter.upload === 'function',
};
}
+36
View File
@@ -0,0 +1,36 @@
// 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 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();
});
});
+391
View File
@@ -0,0 +1,391 @@
/* 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-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);
}
.miu-reaction.is-mine {
border-color: var(--miu-accent);
}
.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;
gap: 8px;
padding: 12px;
border-top: 1px solid var(--miu-border);
}
.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;
}
.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);
}
/* 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/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/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,
},
});
@@ -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);
+401 -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,39 @@ 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
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 +523,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 +669,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 +692,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 +1629,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 +1655,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 +1903,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 +1920,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 +2157,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 +2186,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 +2215,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 +2271,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 +2474,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 +2494,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 +2531,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 +2570,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 +2677,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 +2690,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 +2813,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 +2859,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 +2930,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 +2975,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 +3030,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 +3045,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 +3196,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 +3284,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 +3299,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 +3524,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 +3540,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 +3562,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 +3602,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 +3680,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 +3942,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 +3972,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 +4664,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 +4694,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 +5010,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
ansi-styles@5.2.0: {}
ansis@4.2.0: {}
any-promise@1.3.0: {}
@@ -4749,6 +5020,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 +5248,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 +5268,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
deep-eql@5.0.2: {}
deepmerge-ts@7.1.5: {}
@@ -4999,8 +5286,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 +5355,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 +5658,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 +5670,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 +5686,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 +5723,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 +5751,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 +5870,8 @@ snapshots:
loupe@3.2.1: {}
lru-cache@10.4.3: {}
lru-cache@11.5.1: {}
lru-cache@5.1.1:
@@ -5544,6 +5883,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 +5981,8 @@ snapshots:
notepack.io@3.0.1: {}
nwsapi@2.2.24: {}
nypm@0.6.8:
dependencies:
citty: 0.2.2
@@ -5689,6 +6032,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 +6086,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 +6134,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 +6208,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrweb-cssom@0.8.0: {}
rxjs@7.8.1:
dependencies:
tslib: 2.8.1
@@ -5865,6 +6222,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 +6414,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 +6458,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 +6472,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 +6626,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 +6653,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.0.1
jsdom: 26.1.0
transitivePeerDependencies:
- jiti
- less
@@ -6290,6 +6668,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 +6690,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
webidl-conversions@7.0.0: {}
webpack-node-externals@3.0.0: {}
webpack-sources@3.5.0: {}
@@ -6352,6 +6736,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 +6764,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'],