MicProtocol / apps /web /src /components /chat /ChatPanel.tsx
Benaah's picture
Upload folder using huggingface_hub
fb12607 verified
Raw History Blame Contribute Delete
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>
);
}