"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { restoreAnnotationScrollPosition } from "../core/annotation-target"; import { captureInteractionState } from "../core/capture"; import { getRepliesForParent } from "../core/comment-threads"; import { commentToLiveState } from "../core/restore"; import { normalizeAnnotationViewport } from "../core/normalize-viewport"; import { COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE } from "@prototype/lib/prototypes/comment-storage-setup-prompt"; import { fetchPrototypeStorageStatus, isPrototypeStorageFullyConfigured } from "@prototype/lib/prototypes/prototype-storage-status"; import { resolveCommentAuthorName, resolveCommentTeamName } from "@prototype/lib/prototypes/workspace-profile"; import { useWorkspaceProfile } from "@prototype/lib/prototypes/use-workspace-profile"; import { useTrackEvent } from "@prototype/lib/prototypes/telemetry/prototype-telemetry-context"; import { createRemoteStorageAdapter } from "../core/storage"; import type { CommentAnnotation, CommentStore } from "../core/types"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const CommentContext = createContext | null>(null); type CommentProviderProps = { children: ReactNode; slug: string; getLiveState: () => TState; onRestore: (live: TState) => void; }; export function CommentProvider({ children, slug, getLiveState, onRestore, }: CommentProviderProps) { const [annotations, setAnnotations] = useState[]>([]); const [storageReady, setStorageReady] = useState(false); const [storageConfigured, setStorageConfigured] = useState(null); const [storageError, setStorageError] = useState(null); const getLiveStateRef = useRef(getLiveState); const onRestoreRef = useRef(onRestore); useEffect(() => { getLiveStateRef.current = getLiveState; }, [getLiveState]); useEffect(() => { onRestoreRef.current = onRestore; }, [onRestore]); const workspaceProfile = useWorkspaceProfile(); const teamName = resolveCommentTeamName(workspaceProfile); const track = useTrackEvent(); const handleLoadError = useCallback((message: string) => { setStorageError(message); }, []); const storage = useMemo( () => createRemoteStorageAdapter>( slug, undefined, handleLoadError, ), [slug, handleLoadError], ); const persistAnnotation = useCallback( async (annotation: CommentAnnotation) => { await storage.upsert(annotation); }, [storage], ); useEffect(() => { let cancelled = false; async function loadAnnotations() { setStorageReady(false); setStorageError(null); setStorageConfigured(null); try { const status = await fetchPrototypeStorageStatus(slug); if (cancelled) return; const fullyConfigured = isPrototypeStorageFullyConfigured(status); setStorageConfigured(fullyConfigured); if (!fullyConfigured) { setStorageError(COMMENT_STORAGE_NOT_CONFIGURED_MESSAGE); setAnnotations([]); return; } const stored = await storage.load(); if (cancelled) return; setAnnotations(stored); } catch (error) { if (cancelled) return; const message = error instanceof Error ? error.message : "Failed to load comments."; setStorageError(message); setAnnotations([]); setStorageConfigured((current) => current ?? true); } finally { if (!cancelled) setStorageReady(true); } } void loadAnnotations(); const reloadFromRemote = () => { if (document.visibilityState !== "visible") return; void Promise.resolve(storage.load()).then((stored) => { if (!cancelled) setAnnotations(stored); }).catch(() => { // Keep showing the current comments if a background refresh fails. }); }; document.addEventListener("visibilitychange", reloadFromRemote); window.addEventListener("focus", reloadFromRemote); return () => { cancelled = true; document.removeEventListener("visibilitychange", reloadFromRemote); window.removeEventListener("focus", reloadFromRemote); }; }, [storage]); const addAnnotation = useCallback( (fields: Omit, "interactionState">): CommentAnnotation => { if (!storageReady || storageError) { throw new Error(storageError ?? "Comments are still loading."); } const { captureViewport, viewportBoundingBox } = normalizeAnnotationViewport(fields); const fullAnnotation: CommentAnnotation = { ...fields, teamName, captureViewport, viewportBoundingBox, interactionState: captureInteractionState(getLiveStateRef.current()), }; setAnnotations((prev) => [...prev, fullAnnotation]); void persistAnnotation(fullAnnotation).catch(() => { // errors are reported via onError in the remote adapter }); track( "comment.created", { channel: fullAnnotation.channel ?? "comment", targetId: fullAnnotation.targetId, hasScreenshot: Boolean(fields.screenshot), }, { slug }, ); return fullAnnotation; }, [storageReady, storageError, persistAnnotation, teamName, track, slug], ); const addReply = useCallback( (parentId: string, comment: string): CommentAnnotation => { if (!storageReady || storageError) { throw new Error(storageError ?? "Comments are still loading."); } const parent = annotations.find((a) => a.id === parentId && !a.deleted); if (!parent) { throw new Error("Parent comment not found."); } const trimmed = comment.trim(); if (!trimmed) { throw new Error("Reply cannot be empty."); } const reply: CommentAnnotation = { id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, parentId: parent.id, comment: trimmed, timestamp: Date.now(), authorName: resolveCommentAuthorName(workspaceProfile), teamName, x: parent.x, y: parent.y, element: parent.element, elementPath: parent.elementPath, targetId: parent.targetId, positionAnchor: parent.positionAnchor, interactionState: parent.interactionState, channel: parent.channel, }; setAnnotations((prev) => [...prev, reply]); void persistAnnotation(reply).catch(() => {}); track( "comment.replied", { parentId: parent.id, channel: parent.channel ?? "comment" }, { slug }, ); return reply; }, [annotations, storageReady, storageError, persistAnnotation, workspaceProfile, teamName, track, slug], ); const updateAnnotation = useCallback( (id: string, comment: string) => { let nextAnnotation: CommentAnnotation | undefined; setAnnotations((prev) => { const current = prev.find((a) => a.id === id); if (!current) return prev; nextAnnotation = { ...current, comment }; return prev.map((a) => (a.id === id ? nextAnnotation! : a)); }); if (nextAnnotation) { void persistAnnotation(nextAnnotation).catch(() => {}); track("comment.edited", { id }, { slug }); } }, [persistAnnotation, track, slug], ); const resolveAnnotation = useCallback( (id: string) => { let nextAnnotation: CommentAnnotation | undefined; setAnnotations((prev) => { const current = prev.find((a) => a.id === id); if (!current) return prev; nextAnnotation = { ...current, status: current.status === "resolved" ? "pending" : "resolved", }; return prev.map((a) => (a.id === id ? nextAnnotation! : a)); }); if (nextAnnotation) { void persistAnnotation(nextAnnotation).catch(() => {}); track( "comment.resolved", { id, status: nextAnnotation.status }, { slug }, ); } }, [persistAnnotation, track, slug], ); const deleteAnnotation = useCallback( (id: string) => { let toDelete: CommentAnnotation[] = []; setAnnotations((prev) => { const target = prev.find((a) => a.id === id); if (!target) return prev; const replyIds = new Set( getRepliesForParent(prev, id).map((reply) => reply.id), ); toDelete = [ target, ...prev.filter((a) => replyIds.has(a.id)), ]; const removeIds = new Set([id, ...replyIds]); return prev.filter((a) => !removeIds.has(a.id)); }); for (const annotation of toDelete) { void persistAnnotation({ ...annotation, deleted: true }).catch(() => {}); } if (toDelete.length > 0) { track( "comment.deleted", { id, replyCount: toDelete.length - 1 }, { slug }, ); } }, [persistAnnotation, track, slug], ); const clearAllAnnotations = useCallback(() => { let visible: CommentAnnotation[] = []; setAnnotations((prev) => { visible = prev; return []; }); for (const annotation of visible) { void persistAnnotation({ ...annotation, deleted: true }).catch(() => {}); } }, [persistAnnotation]); const restoreComment = useCallback( (id: string): CommentAnnotation | undefined => { const annotation = annotations.find((a) => a.id === id); if (!annotation) return undefined; const live = commentToLiveState(annotation.interactionState); onRestoreRef.current(live); track("comment.restored", { id, targetId: annotation.targetId }, { slug }); requestAnimationFrame(() => { requestAnimationFrame(() => { restoreAnnotationScrollPosition(annotation.captureViewport, { targetId: annotation.targetId, }); }); }); return annotation; }, [annotations, track, slug], ); const value = useMemo( (): CommentStore => ({ slug, annotations, storageReady, storageConfigured, storageError, addAnnotation, addReply, updateAnnotation, resolveAnnotation, deleteAnnotation, clearAllAnnotations, restoreComment, }), [ slug, annotations, storageReady, storageConfigured, storageError, addAnnotation, addReply, updateAnnotation, resolveAnnotation, deleteAnnotation, clearAllAnnotations, restoreComment, ], ); return ( {children} ); } export function useCommentStore(): CommentStore { const context = useContext(CommentContext); if (!context) { throw new Error("useCommentStore must be used within CommentProvider"); } return context as CommentStore; } export function useCommentStoreOptional(): CommentStore | null { return useContext(CommentContext) as CommentStore | null; }