"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([]); const [draft, setDraft] = useState(""); const [typingUsers, setTypingUsers] = useState>({}); const [presence, setPresence] = useState([]); const [lastSeq, setLastSeq] = useState(0); const listRef = useRef(null); const typingTimersRef = useRef>>({}); 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) => { 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 (

Conversation

{connected ? ( <> Live · {presence.length} online ) : authError ? ( auth: {authError} ) : ( connecting… )}

{messages.map((m) => ( props.onMediaTimestamp?.(s)} onConvertToTask={(id) => { if (m.body) props.onConvertToTask?.(id, m.body); }} /> ))} {typingList.length > 0 && (
{typingList.length === 1 ? "Someone is typing…" : `${typingList.length} people are typing…`}
)}