feat(demo): P2.7 Vite React two-pane realtime demo + dev token endpoint (P2 complete)

apps/message-demo: Alice creates a thread, Bob joins; live two-way chat, typing,
read receipts (useMessages now exposes reads[]). Dev-only /v1/dev/token endpoint
(gated by IIOS_DEV_TOKENS) so the browser can auth. .env autoloaded (dotenv).
Realtime smoke script passes end-to-end (message + unread + receipt).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 01:36:14 +05:30
parent eff3c615d5
commit 4d05f7c1b1
14 changed files with 960 additions and 3 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>IIOS P2 — Message Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
{
"name": "message-demo",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@insignia/iios-kernel-client": "workspace:*",
"@insignia/iios-message-web": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { MessageProvider, useThread, useMessages } from '@insignia/iios-message-web';
const SERVICE = 'http://localhost:3200';
const APP_ID = 'portal-demo';
async function devToken(userId: string, name: string): Promise<string> {
const r = await fetch(`${SERVICE}/v1/dev/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId: APP_ID, userId, name }),
});
if (!r.ok) throw new Error(`devToken ${r.status} (is the service running with IIOS_DEV_TOKENS=1?)`);
return ((await r.json()) as { token: string }).token;
}
function ChatInner({
label,
threadId,
onCreated,
}: {
label: string;
threadId: string | null;
onCreated?: (id: string) => void;
}) {
const { open } = useThread();
const [tid, setTid] = useState<string | null>(threadId);
const [text, setText] = useState('');
useEffect(() => {
if (threadId) {
setTid(threadId);
return;
}
if (onCreated) {
void open().then((id) => {
setTid(id);
onCreated(id);
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threadId]);
const { messages, send, typing, typingUsers, markRead, reads } = useMessages(tid);
return (
<div style={{ flex: 1, border: '1px solid #ccc', borderRadius: 8, padding: 12, margin: 8, fontFamily: 'sans-serif' }}>
<h3 style={{ marginTop: 0 }}>{label}</h3>
<div style={{ color: '#888', fontSize: 12 }}>thread: {tid ?? '…'}</div>
<div style={{ height: 240, overflow: 'auto', background: '#fafafa', padding: 8, margin: '8px 0' }}>
{messages.map((m) => (
<div key={m.id} style={{ padding: '2px 0' }}>
<b>{m.senderActorId.slice(0, 6)}:</b> {m.content} {reads.includes(m.id) ? '✓✓' : ''}
</div>
))}
{typingUsers.length > 0 && <em style={{ color: '#888' }}>{typingUsers.join(', ')} typing</em>}
</div>
<input
value={text}
placeholder="type a message + Enter"
style={{ width: '100%', padding: 6 }}
onChange={(e) => {
setText(e.target.value);
typing();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && text.trim()) {
void send(text.trim());
setText('');
}
}}
/>
<button
style={{ marginTop: 8 }}
onClick={() => {
const last = messages.at(-1);
if (last) void markRead(last.id);
}}
>
mark last read
</button>
</div>
);
}
function Pane(props: { token: string | null; label: string; threadId: string | null; onCreated?: (id: string) => void }) {
if (!props.token) return <div style={{ flex: 1, margin: 8 }}>loading {props.label}</div>;
return (
<MessageProvider serviceUrl={SERVICE} token={props.token}>
<ChatInner label={props.label} threadId={props.threadId} onCreated={props.onCreated} />
</MessageProvider>
);
}
export function App() {
const [alice, setAlice] = useState<string | null>(null);
const [bob, setBob] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
void devToken('alice', 'Alice').then(setAlice);
void devToken('bob', 'Bob').then(setBob);
}, []);
return (
<div style={{ padding: 16 }}>
<h2 style={{ fontFamily: 'sans-serif' }}>IIOS P2 realtime demo (Alice Bob)</h2>
<p style={{ fontFamily: 'sans-serif', color: '#666' }}>
Type in either pane it appears live in the other. = read receipt.
</p>
<div style={{ display: 'flex' }}>
<Pane token={alice} label="Alice (creates thread)" threadId={null} onCreated={setThreadId} />
<Pane token={bob} label="Bob (joins)" threadId={threadId} />
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
const el = document.getElementById('root');
if (el) createRoot(el).render(<App />);
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"types": ["react", "react-dom"],
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 5173 },
});