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
+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 };
}