35 Commits

Author SHA1 Message Date
maaz519 973b6a77eb feat(messaging-ui): Attachment carries contentRef + sizeBytes for messenger media (0.1.4)
Extend the messaging Attachment type with optional storage fields so an adapter's
upload() can return a sendable reference (the socket needs contentRef/mimeType/
sizeBytes to persist the message part) while keeping the display url. Enables
messenger attachments end-to-end in host adapters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:10:31 +05:30
maaz519 3c5c6964e2 fix(messaging-ui): mail cards no longer clip + emoji picker portals above overflow (0.1.3)
- Mail reader: .miu-mail-msg gets flex:0 0 auto so cards keep their natural height
  in the scrolling column instead of being compressed + clipped by overflow:hidden.
- Reaction/emoji picker renders through a PopoverPortal to <body> (positioned +
  themed) so it floats above the message list instead of being cut by the scroll
  container's overflow. 72 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 03:10:31 +05:30
maaz519 2753a8a367 Merge pull request 'Feat/messaging ui foundation' (#7) from feat/messaging-ui-foundation into dev
Reviewed-on: #7
2026-07-22 20:43:35 +00:00
maaz519 af45982176 Merge branch 'dev' into feat/messaging-ui-foundation 2026-07-23 02:13:10 +05:30
maaz519 0ee63c139f feat(media): orphan cleanup — track objects + reap unreferenced attachments
Attachments upload direct-to-storage on attach, so an abandoned attach (removed,
cancelled, tab closed) would linger forever. Add IiosMediaObject: a row is recorded
when bytes land (MediaService.put); a scheduled sweepOrphans() reaps objects past a
grace window (IIOS_MEDIA_ORPHAN_GRACE_MS, default 24h) that no IiosMessagePart
references (derived live — no flag to drift). Storage delete via the StoragePort;
best-effort + idempotent. Gated on IIOS_MEDIA_GC_INTERVAL_MS (off by default).
Migration 20260722193958_media_object_tracking. 10 media tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:19:11 +05:30
maaz519 22eaf0f654 feat(messaging-ui): Gmail-style uploading chip while an attachment uploads (0.1.2)
Picking a file now shows an immediate chip with the filename + a spinner while
the bytes upload (mail compose, mail reply, and the messenger composer), instead
of only appearing once upload finishes. Respects prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 01:04:52 +05:30
maaz519 8878ee8c54 feat(messaging-ui): mail attachment download + scrollable reader (0.1.1)
- Attachment chips in the mail reader are now clickable: new InboxAdapter
  downloadAttachment() resolves a short-lived URL, opened in a new tab.
- Mail reader scrolls again: convert the .miu-detail/.miu-mail height:100%
  chain to flex fill so a long thread scrolls within the pane instead of
  being clipped. MockInboxAdapter implements download. Bump to 0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:53:08 +05:30
maaz519 ba264f401d Merge pull request 'Feat/messaging ui foundation' (#6) from feat/messaging-ui-foundation into dev
Reviewed-on: #6
2026-07-22 18:46:31 +00:00
maaz519 c4254749a4 fix(messaging-ui): stop Start-chat/Create buttons stretching full width
The new-conversation + channel-create forms are flex columns, so the submit
button stretched to full width and read as thin. Give it align-self:flex-start
+ a 38px height so it sizes to its label like a normal button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 4faf05fee9 feat(messaging-ui): start direct messages + groups (Slack-style people picker)
Add a 'New message' + on the Direct messages section that opens a people picker
(NewConversation): pick one person -> DM, two or more -> group with an optional
name, via the adapter's existing openThread({participantIds, membership, subject}).
Expose directory() on MessagingAdapter (org people list); MockAdapter implements
it + dedupes 1:1 DMs. 72 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 9882177c59 fix(messaging-ui): portal compose modal to <body> so it renders above host chrome
A host wrapper like .view{position:relative;z-index:1} creates a stacking context
that traps the modal's fixed overlay below a sibling sticky header, no matter its
z-index. Render the modal through a ModalPortal (createPortal to document.body) so
it escapes the host stacking context + overflow entirely; the portal root copies
the SDK theme tokens from the live surface (synchronously, no unstyled paint) and
carries dark defaults as a fallback. Adds react-dom peer dep. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 f16d7986c7 fix(migration): regenerate provider_credentials via prisma engine, not hand-written SQL
Replace the hand-authored migration with one produced by `prisma migrate diff`
(offline, engine-generated) and verified by replaying the full 24-migration chain
into a throwaway Postgres DB. Same DDL, but generated through Prisma tooling so the
migration history stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 ddd628ab6f feat(capability): per-scope BYO provider credentials + Twilio SMS egress
IIOS becomes the tenant's integration/credential hub for anything it sends.
New IiosProviderCredential (one row per scope+providerType) seals the secret
with AES-256-GCM under IIOS_CRED_KEY (secret-crypto.ts); only non-secret
displayHints are ever read back. ProviderCredentialService upsert/resolve/status;
TwilioSmsProvider resolves the caller's own creds per scope at send time and
POSTs to Twilio (fail-closed NOT_CONFIGURED when unset). Registered over the
SMS sandbox only when the platform key + store are present. PUT/GET
/v1/providers/:type/credentials (scope-fenced, masked reads). 16 new unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 2aa2893973 feat(messaging-ui): attachments in inbox mail — reply + compose upload
InboxAdapter gains uploadAttachment + attachment params on mailReply/
composeInternal/composeExternal. MailReader reply and ComposeModal grow an
attach affordance (paperclip + pending chips); mail history renders sent
attachments. MockInboxAdapter implements upload. 67 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 2f24a95ef4 feat(messaging-ui): Inbox domain — unified work items + mail (contract + UI + mock)
Second domain in the SDK, mirroring messaging's adapter/provider/hooks/components:
- InboxAdapter (listInbox unified feed + transition + mailHistory/mailReply +
  optional directory/composeInternal/composeExternal), InboxProvider/useInboxAdapter
- hooks: useInbox (filter + transition), useMailThread (history + reply), useCompose
- <Inbox> (filter tabs, unified list, detail pane), <MailReader> (HTML in a
  sandboxed iframe + reply), compose modal (in-app / email); themeable styles
- MockInboxAdapter (seeded mentions/needs-reply/alert + folded mail threads +
  directory) shipped from ./adapters/mock-inbox
- 6 inbox tests (mock unify/transition/reply + render open/reply/compose); 64 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 d02cd152de fix(messaging-ui): reaction picker opens downward, no longer clips
The emoji picker used bottom:100% and was clipped by the message list's
overflow when reacting to a message near the top (and could spill past the
right edge). Open it downward + edge-anchored (right for others' messages,
left for my own) with width:max-content so all emojis stay visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 fa93173b1f feat(messaging-ui): Slack-style threaded-replies side panel
Replies (messages with parentInteractionId) now open in a right-hand ThreadPane
instead of inline quoting:
- extracted a shared Composer (draft + @mention autocomplete + attach) and
  MessageItem (bubble + mentions + attachment + reactions + reply affordance)
- Thread shows top-level messages only; a message with replies gets a
  '💬 N replies' link, and hovering shows 💬 'Reply in thread'
- ThreadPane renders the root + its replies + a composer that posts back with
  parentInteractionId; Messenger becomes 3-column (list | thread | pane),
  pane closes on conversation switch
- no contract/adapter/backend change (parentInteractionId was already wired)
- thread-pane render test (open → reply → parent shows the count); 58 green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:15:21 +05:30
maaz519 7a0fb2ca97 feat(messaging-ui): reaction picker + attachments in the Thread
- useMessages exposes upload(); Thread gains a hover reaction picker (react to
  a message) and an attach button (upload → stage → send), plus inline image /
  file rendering of message attachments — all capability-degrading (hidden when
  the adapter lacks react/upload)
- themeable styles for the picker, attach button, staged chip, and attachments
- 57 tests still green

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