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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { MessageProvider, useThread, useMessages } from './react';
|
||||
export type { Message } from '@insignia/iios-kernel-client';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'],
|
||||
});
|
||||
Reference in New Issue
Block a user