feat(sdk): P2.6 @insignia/iios-message-web (React hooks) + allowlist boundary

MessageProvider/useThread/useMessages over the kernel-client socket (live append,
typing, dedupe). Reconnect test (re-opens active thread). Rewrote import-boundary
as an allowlist so SDK<->service sibling imports are forbidden both ways. 28 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 01:29:25 +05:30
parent 5a590c7fda
commit eff3c615d5
8 changed files with 262 additions and 19 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@insignia/iios-message-web",
"version": "0.0.0",
"private": true,
"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" } },
"files": ["dist"],
"scripts": {
"build": "tsup",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@insignia/iios-kernel-client": "workspace:*"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0",
"tsup": "^8.3.5",
"typescript": "^5.7.3"
}
}
+2
View File
@@ -0,0 +1,2 @@
export { MessageProvider, useThread, useMessages } from './react';
export type { Message } from '@insignia/iios-kernel-client';
+101
View File
@@ -0,0 +1,101 @@
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { MessageSocket, type Message } from '@insignia/iios-kernel-client';
interface MessageContextValue {
socket: MessageSocket;
}
const MessageContext = createContext<MessageContextValue | null>(null);
/** Wraps a MessageSocket and provides it to the message hooks. */
export function MessageProvider({
serviceUrl,
token,
children,
}: {
serviceUrl: string;
token: string;
children: React.ReactNode;
}): React.ReactElement {
const socket = useMemo(() => new MessageSocket({ serviceUrl, token }), [serviceUrl, token]);
useEffect(() => () => socket.disconnect(), [socket]);
return <MessageContext.Provider value={{ socket }}>{children}</MessageContext.Provider>;
}
function useSocket(): MessageSocket {
const ctx = useContext(MessageContext);
if (!ctx) throw new Error('useThread/useMessages must be used within <MessageProvider>');
return ctx.socket;
}
/** Open or create a thread; returns the resolved thread id + status. */
export function useThread(): {
threadId: string | null;
status: string;
open: (id?: string) => Promise<string>;
} {
const socket = useSocket();
const [threadId, setThreadId] = useState<string | null>(null);
const [status, setStatus] = useState('OPEN');
const open = async (id?: string): Promise<string> => {
const r = await socket.openThread(id);
setThreadId(r.threadId);
setStatus(r.status);
return r.threadId;
};
return { threadId, status, open };
}
/** Live messages for a thread + send/read/typing actions. */
export function useMessages(threadId: string | null): {
messages: Message[];
send: (content: string, opts?: { contentRef?: string }) => Promise<void>;
markRead: (interactionId: string) => Promise<void>;
typing: () => void;
typingUsers: string[];
} {
const socket = useSocket();
const [messages, setMessages] = useState<Message[]>([]);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const seen = useRef<Set<string>>(new Set());
useEffect(() => {
if (!threadId) return;
seen.current = new Set();
void socket.openThread(threadId).then((r) => {
seen.current = new Set(r.history.map((m) => m.id));
setMessages(r.history);
});
const offMessage = socket.on('message', (m) => {
if (m.threadId !== threadId || seen.current.has(m.id)) return;
seen.current.add(m.id);
setMessages((prev) => [...prev, m]);
});
const offTyping = socket.on('typing', (e) => {
if (e.threadId !== threadId) return;
setTypingUsers((u) => (u.includes(e.userId) ? u : [...u, e.userId]));
setTimeout(() => setTypingUsers((u) => u.filter((x) => x !== e.userId)), 2500);
});
return () => {
offMessage();
offTyping();
};
}, [socket, threadId]);
const send = async (content: string, opts?: { contentRef?: string }): Promise<void> => {
if (threadId) await socket.sendMessage(threadId, content, opts);
};
const markRead = async (interactionId: string): Promise<void> => {
if (threadId) await socket.markRead(threadId, interactionId);
};
const typing = (): void => {
if (threadId) socket.typing(threadId);
};
return { messages, send, markRead, typing, typingUsers };
}
+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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: true,
clean: true,
external: ['react', 'react-dom', '@insignia/iios-kernel-client'],
});