Spaces:
Running
Running
Download apps/web/src/components/chat/ChatPanel.tsx from Benaah/MicProtocol: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/web/src/components/chat/ChatPanel.tsx
- Command line
-
hf download hf://spaces/Benaah/MicProtocol/apps/web/src/components/chat/ChatPanel.tsx
-
curl -L -o ChatPanel.tsx https://huggingface.co/spaces/Benaah/MicProtocol/resolve/main/apps/web/src/components/chat/ChatPanel.tsx
14.5 kB
| "use client"; | |
| import { | |
| useCallback, | |
| useEffect, | |
| useMemo, | |
| useRef, | |
| useState, | |
| } from "react"; | |
| import { trpc } from "@/lib/trpc"; | |
| import { useRealtime, type RealtimeFrame } from "@/hooks/useRealtime"; | |
| import { formatDistanceToNow } from "date-fns"; | |
| import { clsx } from "@/lib/utils"; | |
| export interface ChatPanelProps { | |
| workspaceId: string; | |
| conversationId: string; | |
| userId: string; | |
| /** Optional callback when a user attaches a media timestamp. */ | |
| onMediaTimestamp?: (seconds: number) => void; | |
| /** Optional callback to convert a message ID into a task. */ | |
| onConvertToTask?: (messageId: string, body: string) => void; | |
| /** Open / closed state. */ | |
| open: boolean; | |
| onClose: () => void; | |
| /** Render-time height customization for inline usage. */ | |
| className?: string; | |
| /** Optional inline styles applied to the root panel. */ | |
| style?: React.CSSProperties; | |
| } | |
| interface MessageRow { | |
| id: string; | |
| userId: string | null; | |
| body: string | null; | |
| eventType: string; | |
| metadata?: any; | |
| seq: number; | |
| createdAt: string; | |
| } | |
| function fmtTimestamp(seconds: number): string { | |
| const s = Math.max(0, Math.floor(seconds)); | |
| const h = Math.floor(s / 3600); | |
| const m = Math.floor((s % 3600) / 60); | |
| const sec = s % 60; | |
| const mm = String(m).padStart(2, "0"); | |
| const ss = String(sec).padStart(2, "0"); | |
| return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; | |
| } | |
| /** | |
| * Slide-in chat panel for a single conversation. Renders the persisted | |
| * message log via tRPC, then attaches a WebSocket via `useRealtime` to | |
| * receive live messages / typing / reactions / system events. | |
| * | |
| * Messages can carry `metadata.episodeId` + `metadata.mediaTimestampSeconds` | |
| * to render a clickable "jump to 03:42" link. | |
| */ | |
| export function ChatPanel(props: ChatPanelProps) { | |
| const utils = trpc.useUtils(); | |
| const [messages, setMessages] = useState<MessageRow[]>([]); | |
| const [draft, setDraft] = useState(""); | |
| const [typingUsers, setTypingUsers] = useState<Record<string, number>>({}); | |
| const [presence, setPresence] = useState<any[]>([]); | |
| const [lastSeq, setLastSeq] = useState(0); | |
| const listRef = useRef<HTMLDivElement>(null); | |
| const typingTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({}); | |
| const initial = trpc.chat.listMessages.useQuery({ | |
| conversationId: props.conversationId, | |
| limit: 50, | |
| }); | |
| // Seed the message list from the persisted log. | |
| useEffect(() => { | |
| if (!initial.data) return; | |
| const timer = setTimeout(() => { | |
| const rows: MessageRow[] = initial.data.messages.map((m: any) => ({ | |
| id: m.id, | |
| userId: m.userId, | |
| body: m.body, | |
| eventType: m.eventType, | |
| metadata: m.metadata, | |
| seq: m.seq, | |
| createdAt: m.createdAt, | |
| })); | |
| setMessages(rows); | |
| setLastSeq( | |
| rows.reduce((acc, r) => Math.max(acc, r.seq), 0), | |
| ); | |
| }, 0); | |
| return () => clearTimeout(timer); | |
| }, [initial.data]); | |
| // Auto-scroll on new messages. | |
| useEffect(() => { | |
| const el = listRef.current; | |
| if (el) el.scrollTop = el.scrollHeight; | |
| }, [messages.length]); | |
| // Subscribe to live frames. | |
| const handleFrame = useCallback( | |
| (frame: RealtimeFrame) => { | |
| switch (frame.type) { | |
| case "snapshot": { | |
| const recent = ((frame as any).messages ?? []).map( | |
| (m: any) => ({ | |
| id: m.id ?? `${m.seq}`, | |
| userId: m.userId ?? null, | |
| body: m.body ?? null, | |
| eventType: m.eventType ?? "message", | |
| metadata: m.meta ?? m.metadata ?? {}, | |
| seq: m.seq, | |
| createdAt: m.at ?? new Date().toISOString(), | |
| }), | |
| ); | |
| setMessages(recent); | |
| setPresence((frame as any).presence ?? []); | |
| setLastSeq( | |
| recent.reduce( | |
| (acc: number, r: MessageRow) => | |
| Math.max(acc, r.seq), | |
| 0, | |
| ), | |
| ); | |
| break; | |
| } | |
| case "message": | |
| case "system": { | |
| const m = frame as any; | |
| const row: MessageRow = { | |
| id: m.id ?? `${m.seq}-${m.at}`, | |
| userId: m.userId ?? null, | |
| body: m.body ?? null, | |
| eventType: m.eventType ?? frame.type, | |
| metadata: m.meta ?? m.metadata ?? {}, | |
| seq: m.seq ?? Date.now(), | |
| createdAt: m.at ?? new Date().toISOString(), | |
| }; | |
| setMessages((prev) => | |
| prev.some((p) => p.id === row.id) | |
| ? prev | |
| : [...prev, row], | |
| ); | |
| setLastSeq((s) => Math.max(s, row.seq)); | |
| // Tell the server we read up to here. | |
| break; | |
| } | |
| case "typing": { | |
| const userId = (frame as any).userId as string; | |
| if (!userId || userId === props.userId) break; | |
| setTypingUsers((s) => ({ ...s, [userId]: Date.now() })); | |
| if (typingTimersRef.current[userId]) { | |
| clearTimeout(typingTimersRef.current[userId]); | |
| } | |
| typingTimersRef.current[userId] = setTimeout(() => { | |
| setTypingUsers((s) => { | |
| const c = { ...s }; | |
| delete c[userId]; | |
| return c; | |
| }); | |
| }, 3_000); | |
| break; | |
| } | |
| case "presence": { | |
| setPresence((frame as any).users ?? []); | |
| break; | |
| } | |
| case "reaction": { | |
| // We don't render reaction counts inline yet but | |
| // we DO invalidate the cached message list so a | |
| // refresh picks them up. | |
| utils.chat.listMessages.invalidate({ | |
| conversationId: props.conversationId, | |
| }); | |
| break; | |
| } | |
| default: | |
| break; | |
| } | |
| }, | |
| [props.conversationId, props.userId, utils.chat.listMessages], | |
| ); | |
| const { client, connected, authError } = useRealtime({ | |
| onFrame: handleFrame, | |
| }); | |
| // Subscribe + auto mark read on attach. | |
| useEffect(() => { | |
| if (!client) return; | |
| client.subscribe(props.conversationId); | |
| const interval = setInterval(() => { | |
| if (lastSeq > 0) client.sendRead(props.conversationId, lastSeq); | |
| }, 3_000); | |
| return () => { | |
| client.unsubscribe(props.conversationId); | |
| client.sendRead(props.conversationId, lastSeq); | |
| clearInterval(interval); | |
| }; | |
| }, [client, props.conversationId, lastSeq]); | |
| const sendDraft = useCallback(() => { | |
| const text = draft.trim(); | |
| if (!text || !client) return; | |
| client.sendMessage(props.conversationId, text, { | |
| workspaceId: props.workspaceId, | |
| senderId: props.userId, | |
| }); | |
| setDraft(""); | |
| }, [client, draft, props.conversationId, props.userId, props.workspaceId]); | |
| const onKeyDown = useCallback( | |
| (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendDraft(); | |
| } else if (client) { | |
| client.sendTyping(props.conversationId); | |
| } | |
| }, | |
| [client, sendDraft, props.conversationId], | |
| ); | |
| const typingList = useMemo(() => { | |
| return Object.entries(typingUsers) | |
| .map(([u]) => u) | |
| .filter((u) => u !== props.userId); | |
| }, [typingUsers, props.userId]); | |
| if (!props.open) return null; | |
| return ( | |
| <div | |
| className={clsx( | |
| "fixed right-0 top-0 z-50 flex h-screen w-[420px] flex-col border-l bg-white shadow-2xl", | |
| "dark:border-zinc-800 dark:bg-zinc-950", | |
| props.className, | |
| )} | |
| style={props.style} | |
| role="dialog" | |
| aria-label="Chat" | |
| > | |
| <header className="flex items-center justify-between border-b px-4 py-3 dark:border-zinc-800"> | |
| <div> | |
| <h2 className="text-sm font-semibold">Conversation</h2> | |
| <p className="text-xs text-zinc-500"> | |
| {connected ? ( | |
| <> | |
| <span className="mr-1 inline-block h-2 w-2 rounded-full bg-emerald-500" /> | |
| Live · {presence.length} online | |
| </> | |
| ) : authError ? ( | |
| <span className="text-amber-600">auth: {authError}</span> | |
| ) : ( | |
| <span className="text-zinc-500">connecting…</span> | |
| )} | |
| </p> | |
| </div> | |
| <button | |
| onClick={props.onClose} | |
| className="rounded p-1 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800" | |
| aria-label="Close chat" | |
| > | |
| ✕ | |
| </button> | |
| </header> | |
| <div | |
| ref={listRef} | |
| className="flex-1 space-y-3 overflow-y-auto px-4 py-3" | |
| > | |
| {messages.map((m) => ( | |
| <MessageRowView | |
| key={m.id} | |
| row={m} | |
| mine={m.userId === props.userId} | |
| onJump={(s) => props.onMediaTimestamp?.(s)} | |
| onConvertToTask={(id) => { | |
| if (m.body) props.onConvertToTask?.(id, m.body); | |
| }} | |
| /> | |
| ))} | |
| {typingList.length > 0 && ( | |
| <div className="text-xs italic text-zinc-400"> | |
| {typingList.length === 1 | |
| ? "Someone is typing…" | |
| : `${typingList.length} people are typing…`} | |
| </div> | |
| )} | |
| </div> | |
| <footer className="border-t p-3 dark:border-zinc-800"> | |
| <textarea | |
| value={draft} | |
| onChange={(e) => setDraft(e.target.value)} | |
| onKeyDown={onKeyDown} | |
| placeholder="Write a message…" | |
| rows={2} | |
| className="w-full resize-none rounded-md border bg-transparent p-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 dark:border-zinc-800" | |
| /> | |
| <div className="mt-2 flex items-center justify-between"> | |
| <span className="text-xs text-zinc-400"> | |
| Press Enter to send · Shift+Enter for newline | |
| </span> | |
| <button | |
| onClick={sendDraft} | |
| disabled={!connected || !draft.trim()} | |
| className="rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white disabled:cursor-not-allowed disabled:bg-zinc-300" | |
| > | |
| Send | |
| </button> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } | |
| function MessageRowView({ | |
| row, | |
| mine, | |
| onJump, | |
| onConvertToTask, | |
| }: { | |
| row: MessageRow; | |
| mine: boolean; | |
| onJump: (seconds: number) => void; | |
| onConvertToTask: (id: string) => void; | |
| }) { | |
| const ts = | |
| row.metadata?.mediaTimestampSeconds ?? row.metadata?.timestampSeconds; | |
| const isSystem = row.eventType !== "message"; | |
| const when = useMemo(() => { | |
| try { | |
| return formatDistanceToNow(new Date(row.createdAt), { | |
| addSuffix: true, | |
| }); | |
| } catch { | |
| return row.createdAt; | |
| } | |
| }, [row.createdAt]); | |
| if (isSystem) { | |
| return ( | |
| <div className="my-2 text-center text-xs italic text-zinc-500"> | |
| {row.body ?? `[${row.eventType}]`} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div | |
| className={clsx( | |
| "group flex flex-col rounded-lg px-3 py-2 text-sm", | |
| mine | |
| ? "ml-12 bg-blue-50 dark:bg-blue-950/30" | |
| : "mr-12 bg-zinc-100 dark:bg-zinc-900", | |
| )} | |
| > | |
| <div className="flex items-center justify-between gap-2"> | |
| <span className="text-xs font-semibold"> | |
| {mine ? "You" : row.userId?.slice(0, 6) ?? "System"} | |
| </span> | |
| <span className="text-[10px] uppercase tracking-wide text-zinc-400"> | |
| {when} | |
| </span> | |
| </div> | |
| <div className="mt-1 whitespace-pre-wrap text-zinc-800 dark:text-zinc-200"> | |
| {row.body} | |
| </div> | |
| {typeof ts === "number" && ts >= 0 && ( | |
| <button | |
| onClick={() => onJump(ts)} | |
| className="mt-2 inline-flex items-center gap-1 self-start rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-200" | |
| > | |
| ▶ jump to {fmtTimestamp(ts)} | |
| </button> | |
| )} | |
| {!mine && row.id && ( | |
| <button | |
| onClick={() => onConvertToTask(row.id)} | |
| className="mt-1 self-start text-[10px] text-zinc-400 opacity-0 hover:underline group-hover:opacity-100" | |
| > | |
| + convert to task | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } | |