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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />);
|
||||
Reference in New Issue
Block a user