import { useActionMutation } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconSend, IconCheck, IconMoodSmile, IconCornerDownRight, IconDots, IconMessageCircle, IconPlus, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { type Ref, useEffect, useMemo, useRef, useState } from "react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { msToClock } from "./scrubber"; function makeTempId() { if ( typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ) { return `temp_${crypto.randomUUID()}`; } return `temp_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; } // The shape of the cached value depends on the route — the authenticated // player route caches `get-recording-player-data` ({ comments, ... }) while // the public share route caches a wrapped fetch response // ({ ok, status, data: { comments, ... } }). Both feed into this panel, so we // don't assume a shape — the parent passes lenses. type CommentsLens = { selectComments: (data: unknown) => Comment[] | undefined; applyComments: (data: unknown, next: Comment[]) => unknown; }; const defaultLens: CommentsLens = { selectComments: (data) => (data as { comments?: Comment[] } | undefined)?.comments, applyComments: (data, next) => data ? { ...(data as object), comments: next } : data, }; const COMMENT_EMOJIS = ["👍", "❤️", "🔥", "👏", "🎉", "😂"]; export interface Comment { id: string; threadId: string; parentId: string | null; authorEmail: string; authorName: string | null; content: string; videoTimestampMs: number; emojiReactionsJson: string; resolved: boolean; createdAt: string; updatedAt: string; } export interface CommentsPanelProps { recordingId: string; comments: Comment[]; currentMs: number; currentUserEmail?: string; enableComments: boolean; onSeek: (ms: number) => void; /** * The React Query key whose cached value contains this panel's `comments`. * Optimistic updates patch this key — passing the wrong one (or omitting * it) means the chip / new-comment row won't appear until the next refetch. */ queryKey: readonly unknown[]; /** * Optional lenses for selecting / replacing the comments array inside the * cached value. Defaults match the authenticated `get-recording-player-data` * shape (`{ comments, ... }`). The public share route wraps comments under * `data.comments` and supplies its own lenses. */ selectComments?: CommentsLens["selectComments"]; applyComments?: CommentsLens["applyComments"]; /** * If provided, this callback is invoked instead of firing the comment / * reaction mutation when the viewer is not signed in. Use it to surface a * sign-in prompt on the public share page. */ onUnauthenticated?: (intent: "comment" | "react") => void; /** * The public share page uses a quieter Loom-style activity panel: * composer first, empty state centered below. The authenticated recording * editor keeps the denser bottom-composer layout. */ presentation?: "default" | "share"; } export function CommentsPanel(props: CommentsPanelProps) { const { recordingId, comments, currentMs, currentUserEmail, enableComments, onSeek, onUnauthenticated, queryKey, selectComments = defaultLens.selectComments, applyComments = defaultLens.applyComments, presentation = "default", } = props; const isSignedIn = !!currentUserEmail; const isSharePresentation = presentation === "share"; const [draft, setDraft] = useState(""); const [replyDraft, setReplyDraft] = useState(""); const [replyTo, setReplyTo] = useState(null); const replyComposerRef = useRef(null); const queryClient = useQueryClient(); const patchComments = (updater: (prev: Comment[]) => Comment[]) => { queryClient.setQueryData(queryKey, (old: unknown) => { if (!old) return old; const current = selectComments(old) ?? []; return applyComments(old, updater(current)); }); }; const addComment = useActionMutation("add-comment", { onMutate: async (vars: any) => { await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); const tempId = makeTempId(); const now = new Date().toISOString(); const optimistic: Comment = { id: tempId, threadId: vars.threadId ?? tempId, parentId: vars.parentId ?? null, authorEmail: currentUserEmail ?? "", authorName: null, content: vars.content, videoTimestampMs: vars.videoTimestampMs ?? 0, emojiReactionsJson: "{}", resolved: false, createdAt: now, updatedAt: now, }; patchComments((list) => [...list, optimistic]); return { prev, tempId }; }, onError: (_err, _vars, ctx: any) => { if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); }, onSuccess: (data: any, _vars, ctx: any) => { if (!ctx?.tempId || !data?.id) return; patchComments((list) => list.map((c) => c.id === ctx.tempId ? { ...c, id: data.id, threadId: data.threadId ?? c.threadId } : c, ), ); }, }); const resolve = useActionMutation("resolve-comment", { onMutate: async (vars: any) => { await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); patchComments((list) => list.map((c) => c.id === vars.id ? { ...c, resolved: typeof vars.resolved === "boolean" ? vars.resolved : !c.resolved, } : c, ), ); return { prev }; }, onError: (_err, _vars, ctx: any) => { if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); }, }); const reactToComment = useActionMutation("react-to-comment", { onMutate: async (vars: any) => { await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); const currentUser = currentUserEmail; if (!currentUser) return { prev }; patchComments((commentList) => commentList.map((comment) => { if (comment.id !== vars.commentId) return comment; let reactions: Record = {}; try { const parsed = JSON.parse(comment.emojiReactionsJson || "{}"); if (parsed && typeof parsed === "object") { reactions = parsed as Record; } } catch {} const reactingUsers = Array.isArray(reactions[vars.emoji]) ? reactions[vars.emoji] : []; const userAlreadyReacted = reactingUsers.includes(currentUser); const updatedReactingUsers = userAlreadyReacted ? reactingUsers.filter((email) => email !== currentUser) : [...reactingUsers, currentUser]; const updatedReactions = { ...reactions }; if (updatedReactingUsers.length === 0) { delete updatedReactions[vars.emoji]; } else { updatedReactions[vars.emoji] = updatedReactingUsers; } return { ...comment, emojiReactionsJson: JSON.stringify(updatedReactions), }; }), ); return { prev }; }, onError: (_err, _vars, ctx: any) => { if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); }, onSuccess: (data: any, vars: any) => { if (!data?.reactions) return; patchComments((list) => list.map((c) => c.id === vars.commentId ? { ...c, emojiReactionsJson: JSON.stringify(data.reactions) } : c, ), ); }, }); const remove = useActionMutation("delete-comment", { onMutate: async (vars: any) => { await queryClient.cancelQueries({ queryKey }); const prev = queryClient.getQueryData(queryKey); // Deleting a root comment cascades to its replies server-side, so mirror // that here: drop the target comment and any descendants in the same // thread whose parent chain leads back to it. patchComments((list) => { const target = list.find((c) => c.id === vars.id); if (!target) return list; const isRoot = target.parentId == null; if (isRoot) { return list.filter((c) => c.threadId !== target.threadId); } return list.filter((c) => c.id !== vars.id); }); return { prev }; }, onError: (_err, _vars, ctx: any) => { if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev); }, }); // Group by thread const threads = useMemo(() => { const map = new Map(); comments.forEach((c) => { const list = map.get(c.threadId) ?? []; list.push(c); map.set(c.threadId, list); }); // Sort within threads by createdAt return Array.from(map.values()).map((list) => list.slice().sort((a, b) => a.createdAt.localeCompare(b.createdAt)), ); }, [comments]); // Sort threads by the first comment's videoTimestampMs const sortedThreads = useMemo( () => threads.slice().sort((a, b) => { return (a[0]?.videoTimestampMs ?? 0) - (b[0]?.videoTimestampMs ?? 0); }), [threads], ); function submitDraft(value: string, target: Comment | null) { const text = value.trim(); if (!text) return; if (!isSignedIn && onUnauthenticated) { onUnauthenticated("comment"); return; } const vars = target ? { recordingId, content: text, videoTimestampMs: target.videoTimestampMs, threadId: target.threadId, parentId: target.id, } : { recordingId, content: text, videoTimestampMs: currentMs }; // Clear composer state before firing the mutation so the UI feels instant — // the optimistic cache patch in onMutate puts the comment in the list. if (target) { setReplyDraft(""); setReplyTo(null); } else { setDraft(""); } addComment.mutate(vars); } function openReply(root: Comment) { if (!isSignedIn && onUnauthenticated) { onUnauthenticated("comment"); return; } setReplyTo(root); setTimeout(() => replyComposerRef.current?.focus(), 0); } const composer = ( submitDraft(draft, null)} onUnauthenticated={onUnauthenticated} /> ); return (
{sortedThreads.length === 0 ? ( ) : (
    {sortedThreads.map((thread) => { const root = thread[0]; const replies = thread.slice(1); return (
  • openReply(root)} onResolve={(id, resolved) => resolve.mutate({ id, resolved }) } onDelete={(id) => remove.mutate({ id })} onReact={(commentId, emoji) => reactToComment.mutate({ commentId, emoji }) } onUnauthenticated={onUnauthenticated} /> {replies.length ? (
      {replies.map((r) => (
    • openReply(root)} onResolve={(id, resolved) => resolve.mutate({ id, resolved }) } onDelete={(id) => remove.mutate({ id })} onReact={(commentId, emoji) => reactToComment.mutate({ commentId, emoji }) } onUnauthenticated={onUnauthenticated} isReply />
    • ))}
    ) : null} {replyTo?.threadId === root.threadId ? ( { setReplyDraft(""); setReplyTo(null); }} onSubmit={() => submitDraft(replyDraft, replyTo)} /> ) : null}
  • ); })}
)}
{isSharePresentation && enableComments ? (
{composer}
) : !isSharePresentation ? ( composer ) : null}
); } function EmptyCommentsState({ enableComments, isSharePresentation, }: { enableComments: boolean; isSharePresentation: boolean; }) { const t = useT(); if (!enableComments) { return (
{t("commentsPanel.disabled")}
); } return (

{t("commentsPanel.beFirst")}

{isSharePresentation ? t("commentsPanel.leaveNotePanel") : t("commentsPanel.leaveNoteTimestamp")}

); } function CommentComposer({ draft, currentMs, currentUserEmail, isSignedIn, isSharePresentation, enableComments, onDraftChange, onSubmit, onUnauthenticated, }: { draft: string; currentMs: number; currentUserEmail?: string; isSignedIn: boolean; isSharePresentation: boolean; enableComments: boolean; onDraftChange: (value: string) => void; onSubmit: () => void; onUnauthenticated?: (intent: "comment" | "react") => void; }) { const t = useT(); if (!enableComments) { return (
{t("commentsPanel.disabled")}
); } if (!isSignedIn && onUnauthenticated) { if (isSharePresentation) { return ( ); } return (
{t("commentsPanel.signInToComment")}
); } return (
{!isSharePresentation ? (
{t("commentsPanel.commentAt")}{" "} {msToClock(currentMs)}
) : null}
{isSharePresentation ? ( {initials(currentUserEmail ?? "Anonymous")} ) : null}