diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d65372c24..e1169867c 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -62,6 +62,7 @@ import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking"; import { Button } from "@/components/ui/button"; import type { Document } from "@/contracts/types/document.types"; import { useChatSessionState } from "@/hooks/use-chat-session-state"; +import { useCommentsElectric } from "@/hooks/use-comments-electric"; import { cn } from "@/lib/utils"; interface ThreadProps { @@ -238,6 +239,9 @@ const Composer: FC = () => { const { isAiResponding, respondingToUserId } = useChatSessionState(threadId); const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id; + // Sync comments for the entire thread via Electric SQL (one subscription per thread) + useCommentsElectric(threadId); + // Auto-focus editor on new chat page after mount useEffect(() => { if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) { diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts new file mode 100644 index 000000000..83a019ef3 --- /dev/null +++ b/surfsense_web/hooks/use-comments-electric.ts @@ -0,0 +1,361 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import type { + Comment, + CommentReply, + Author, +} from "@/contracts/types/chat-comments.types"; +import type { Membership } from "@/contracts/types/members.types"; +import type { SyncHandle } from "@/lib/electric/client"; +import { useElectricClient } from "@/lib/electric/context"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +// Raw comment from PGlite local database +interface RawCommentRow { + id: number; + message_id: number; + thread_id: number; + parent_id: number | null; + author_id: string | null; + content: string; + created_at: string; + updated_at: string; +} + +// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN) +const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g; + +type MemberInfo = Pick; + +/** + * Render mentions in content by replacing @[uuid] with @{DisplayName} + */ +function renderMentions(content: string, memberMap: Map): string { + return content.replace(MENTION_PATTERN, (match, uuid) => { + const member = memberMap.get(uuid); + if (member?.user_display_name) { + return `@{${member.user_display_name}}`; + } + return match; + }); +} + +/** + * Build member lookup map from membersData + */ +function buildMemberMap(membersData: Membership[] | undefined): Map { + const map = new Map(); + if (membersData) { + for (const m of membersData) { + map.set(m.user_id, { + user_display_name: m.user_display_name, + user_avatar_url: m.user_avatar_url, + user_email: m.user_email, + }); + } + } + return map; +} + +/** + * Build author object from member data + */ +function buildAuthor(authorId: string | null, memberMap: Map): Author | null { + if (!authorId) return null; + const m = memberMap.get(authorId); + if (!m) return null; + return { + id: authorId, + display_name: m.user_display_name ?? null, + avatar_url: m.user_avatar_url ?? null, + email: m.user_email ?? "", + }; +} + +/** + * Check if a comment has been edited by comparing timestamps. + * Uses a small threshold to handle precision differences. + */ +function isEdited(createdAt: string, updatedAt: string): boolean { + const created = new Date(createdAt).getTime(); + const updated = new Date(updatedAt).getTime(); + // Consider edited if updated_at is more than 1 second after created_at + return updated - created > 1000; +} + +/** + * Transform raw comment to CommentReply + */ +function transformReply( + raw: RawCommentRow, + memberMap: Map, + currentUserId: string | undefined, + isOwner: boolean +): CommentReply { + return { + id: raw.id, + content: raw.content, + content_rendered: renderMentions(raw.content, memberMap), + author: buildAuthor(raw.author_id, memberMap), + created_at: raw.created_at, + updated_at: raw.updated_at, + is_edited: isEdited(raw.created_at, raw.updated_at), + can_edit: currentUserId === raw.author_id, + can_delete: currentUserId === raw.author_id || isOwner, + }; +} + +/** + * Transform raw comments to Comment with replies + */ +function transformComments( + rawComments: RawCommentRow[], + memberMap: Map, + currentUserId: string | undefined, + isOwner: boolean +): Map { + // Group comments by message_id + const byMessage = new Map }>(); + + for (const raw of rawComments) { + if (!byMessage.has(raw.message_id)) { + byMessage.set(raw.message_id, { topLevel: [], replies: new Map() }); + } + const group = byMessage.get(raw.message_id)!; + + if (raw.parent_id === null) { + group.topLevel.push(raw); + } else { + if (!group.replies.has(raw.parent_id)) { + group.replies.set(raw.parent_id, []); + } + group.replies.get(raw.parent_id)!.push(raw); + } + } + + // Transform to Comment objects grouped by message_id + const result = new Map(); + + for (const [messageId, group] of byMessage) { + const comments: Comment[] = group.topLevel.map((raw) => { + const replies = (group.replies.get(raw.id) || []) + .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()) + .map((r) => transformReply(r, memberMap, currentUserId, isOwner)); + + return { + id: raw.id, + message_id: raw.message_id, + content: raw.content, + content_rendered: renderMentions(raw.content, memberMap), + author: buildAuthor(raw.author_id, memberMap), + created_at: raw.created_at, + updated_at: raw.updated_at, + is_edited: isEdited(raw.created_at, raw.updated_at), + can_edit: currentUserId === raw.author_id, + can_delete: currentUserId === raw.author_id || isOwner, + reply_count: replies.length, + replies, + }; + }); + + // Sort by created_at + comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + result.set(messageId, comments); + } + + return result; +} + +/** + * Hook for syncing comments with Electric SQL real-time sync. + * + * Syncs ALL comments for a thread in ONE subscription, then updates + * React Query cache for each message. This avoids N subscriptions for N messages. + * + * @param threadId - The thread ID to sync comments for + */ +export function useCommentsElectric(threadId: number | null) { + const electricClient = useElectricClient(); + const queryClient = useQueryClient(); + + const { data: membersData } = useAtomValue(membersAtom); + const { data: currentUser } = useAtomValue(currentUserAtom); + const { data: myAccess } = useAtomValue(myAccessAtom); + + const memberMap = useMemo(() => buildMemberMap(membersData), [membersData]); + const currentUserId = currentUser?.id; + const isOwner = myAccess?.is_owner ?? false; + + // Use refs for values needed in live query callback to avoid stale closures + const memberMapRef = useRef(memberMap); + const currentUserIdRef = useRef(currentUserId); + const isOwnerRef = useRef(isOwner); + const queryClientRef = useRef(queryClient); + + // Keep refs updated + useEffect(() => { + memberMapRef.current = memberMap; + currentUserIdRef.current = currentUserId; + isOwnerRef.current = isOwner; + queryClientRef.current = queryClient; + }, [memberMap, currentUserId, isOwner, queryClient]); + + const syncHandleRef = useRef(null); + const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); + const syncKeyRef = useRef(null); + + // Stable callback that uses refs for fresh values + const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => { + const commentsByMessage = transformComments( + rows, + memberMapRef.current, + currentUserIdRef.current, + isOwnerRef.current + ); + + for (const [messageId, comments] of commentsByMessage) { + const cacheKey = cacheKeys.comments.byMessage(messageId); + queryClientRef.current.setQueryData(cacheKey, { + comments, + total_count: comments.length, + }); + } + }, []); + + useEffect(() => { + if (!threadId || !electricClient) { + return; + } + + const syncKey = `comments_${threadId}`; + if (syncKeyRef.current === syncKey) { + return; + } + + // Capture in local variable for use in async functions + const client = electricClient; + + let mounted = true; + syncKeyRef.current = syncKey; + + async function startSync() { + try { + const handle = await client.syncShape({ + table: "chat_comments", + where: `thread_id = ${threadId}`, + columns: ["id", "message_id", "thread_id", "parent_id", "author_id", "content", "created_at", "updated_at"], + primaryKey: ["id"], + }); + + if (!handle.isUpToDate && handle.initialSyncPromise) { + try { + await Promise.race([ + handle.initialSyncPromise, + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); + } catch { + // Initial sync timeout - continue anyway + } + } + + if (!mounted) { + handle.unsubscribe(); + return; + } + + syncHandleRef.current = handle; + + // Fetch initial comments and update cache + await fetchAndUpdateCache(); + + // Set up live query for real-time updates + await setupLiveQuery(); + } catch { + // Sync failed - will retry on next mount + } + } + + async function fetchAndUpdateCache() { + try { + const result = await client.db.query( + `SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at + FROM chat_comments + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId] + ); + + if (mounted && result.rows) { + updateReactQueryCache(result.rows); + } + } catch { + // Query failed - data will be fetched from API + } + } + + async function setupLiveQuery() { + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const db = client.db as any; + + if (db.live?.query && typeof db.live.query === "function") { + const liveQuery = await db.live.query( + `SELECT id, message_id, thread_id, parent_id, author_id, content, created_at, updated_at + FROM chat_comments + WHERE thread_id = $1 + ORDER BY created_at ASC`, + [threadId] + ); + + if (!mounted) { + liveQuery.unsubscribe?.(); + return; + } + + // Set initial results + if (liveQuery.initialResults?.rows) { + updateReactQueryCache(liveQuery.initialResults.rows); + } else if (liveQuery.rows) { + updateReactQueryCache(liveQuery.rows); + } + + // Subscribe to changes + if (typeof liveQuery.subscribe === "function") { + liveQuery.subscribe((result: { rows: RawCommentRow[] }) => { + if (mounted && result.rows) { + updateReactQueryCache(result.rows); + } + }); + } + + if (typeof liveQuery.unsubscribe === "function") { + liveQueryRef.current = liveQuery; + } + } + } catch { + // Live query setup failed - will use initial fetch only + } + } + + startSync(); + + return () => { + mounted = false; + syncKeyRef.current = null; + + if (syncHandleRef.current) { + syncHandleRef.current.unsubscribe(); + syncHandleRef.current = null; + } + if (liveQueryRef.current) { + liveQueryRef.current.unsubscribe(); + liveQueryRef.current = null; + } + }; + }, [threadId, electricClient, updateReactQueryCache]); +} diff --git a/surfsense_web/hooks/use-comments-live.ts b/surfsense_web/hooks/use-comments-live.ts deleted file mode 100644 index f4d922888..000000000 --- a/surfsense_web/hooks/use-comments-live.ts +++ /dev/null @@ -1,154 +0,0 @@ -"use client"; - -import { useShape } from "@electric-sql/react"; -import { useAtomValue } from "jotai"; -import { useMemo } from "react"; -import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import type { Comment, CommentReply, Author } from "@/contracts/types/chat-comments.types"; -import type { Membership } from "@/contracts/types/members.types"; -import type { RawComment } from "@/contracts/types/chat-comments.types"; - -const ELECTRIC_URL = process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"; - -// Regex pattern to match @[uuid] mentions (matches backend MENTION_PATTERN) -const MENTION_PATTERN = /@\[([0-9a-fA-F-]{36})\]/g; - -/** - * Member info for building author objects - derived from Membership - */ -type MemberInfo = Pick; - -/** - * Render mentions in content by replacing @[uuid] with @{DisplayName} - */ -function renderMentions(content: string, memberMap: Map): string { - return content.replace(MENTION_PATTERN, (match, uuid) => { - const member = memberMap.get(uuid); - if (member?.user_display_name) { - return `@{${member.user_display_name}}`; - } - return match; - }); -} - -/** - * Hook to get live comments for a specific message. - * Uses Electric SQL for comments + membersAtom (API) for author info. - * Returns data matching the existing Comment type. - */ -export function useCommentsLive(messageId: number | null) { - const { - data: commentsData, - isLoading: commentsLoading, - isError: commentsError, - error: commentsErrorDetails, - } = useShape({ - url: `${ELECTRIC_URL}/v1/shape`, - params: { - table: "chat_comments", - where: `message_id = ${messageId}`, - }, - }); - - const { data: membersData, isLoading: membersLoading } = useAtomValue(membersAtom); - const { data: currentUser } = useAtomValue(currentUserAtom); - const { data: myAccess } = useAtomValue(myAccessAtom); - - const comments = useMemo(() => { - if (!commentsData) return []; - - // Build member lookup map - const memberMap = new Map(); - if (membersData) { - for (const member of membersData) { - memberMap.set(member.user_id, { - user_display_name: member.user_display_name, - user_avatar_url: member.user_avatar_url, - user_email: member.user_email, - }); - } - } - - const currentUserId = currentUser?.id; - const isOwnerOrAdmin = myAccess?.is_owner ?? false; - - // Build author object from member data - const buildAuthor = (authorId: string | null): Author | null => { - if (!authorId) return null; - const member = memberMap.get(authorId); - if (!member) return null; - return { - id: authorId, - display_name: member.user_display_name ?? null, - avatar_url: member.user_avatar_url ?? null, - email: member.user_email ?? "", - }; - }; - - // Transform raw comment to CommentReply - const transformToReply = (raw: RawComment): CommentReply => { - const isEdited = raw.created_at !== raw.updated_at; - const isAuthor = currentUserId === raw.author_id; - - return { - id: raw.id, - content: raw.content, - content_rendered: renderMentions(raw.content, memberMap), - author: buildAuthor(raw.author_id), - created_at: raw.created_at, - updated_at: raw.updated_at, - is_edited: isEdited, - can_edit: isAuthor, - can_delete: isAuthor || isOwnerOrAdmin, - }; - }; - - // Separate top-level comments and replies - const topLevelRaw: RawComment[] = []; - const repliesMap = new Map(); - - for (const raw of commentsData) { - if (raw.parent_id === null) { - topLevelRaw.push(raw); - } else { - const replies = repliesMap.get(raw.parent_id) || []; - replies.push(raw); - repliesMap.set(raw.parent_id, replies); - } - } - - // Transform top-level comments to Comment type - const transformToComment = (raw: RawComment): Comment => { - const isEdited = raw.created_at !== raw.updated_at; - const isAuthor = currentUserId === raw.author_id; - const rawReplies = repliesMap.get(raw.id) || []; - const replies = rawReplies.map(transformToReply); - - return { - id: raw.id, - message_id: raw.message_id, - content: raw.content, - content_rendered: renderMentions(raw.content, memberMap), - author: buildAuthor(raw.author_id), - created_at: raw.created_at, - updated_at: raw.updated_at, - is_edited: isEdited, - can_edit: isAuthor, - can_delete: isAuthor || isOwnerOrAdmin, - reply_count: replies.length, - replies, - }; - }; - - return topLevelRaw.map(transformToComment); - }, [commentsData, membersData, currentUser?.id, myAccess?.is_owner]); - - return { - comments, - commentCount: commentsData?.length ?? 0, - isLoading: commentsLoading || membersLoading, - isError: commentsError, - error: commentsError ? commentsErrorDetails : null, - }; -}