diff --git a/surfsense_web/hooks/use-comments-electric.ts b/surfsense_web/hooks/use-comments-electric.ts index 6ca7748b5..d588504c9 100644 --- a/surfsense_web/hooks/use-comments-electric.ts +++ b/surfsense_web/hooks/use-comments-electric.ts @@ -7,14 +7,10 @@ import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import type { Author, Comment, CommentReply } 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"; +import { queries } from "@/zero/queries"; +import { useQuery } from "@rocicorp/zero/react"; -// Debounce delay for stream updates (ms) -const STREAM_UPDATE_DEBOUNCE_MS = 100; - -// Raw comment from PGlite local database interface RawCommentRow { id: number; message_id: number; @@ -26,14 +22,10 @@ interface RawCommentRow { 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); @@ -44,9 +36,6 @@ function renderMentions(content: string, memberMap: Map): st }); } -/** - * Build member lookup map from membersData - */ function buildMemberMap(membersData: Membership[] | undefined): Map { const map = new Map(); if (membersData) { @@ -61,9 +50,6 @@ function buildMemberMap(membersData: Membership[] | undefined): Map): Author | null { if (!authorId) return null; const m = memberMap.get(authorId); @@ -76,20 +62,12 @@ function buildAuthor(authorId: string | null, memberMap: Map }; } -/** - * 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, @@ -109,16 +87,12 @@ function transformReply( }; } -/** - * 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< number, { topLevel: RawCommentRow[]; replies: Map } @@ -140,7 +114,6 @@ function transformComments( } } - // Transform to Comment objects grouped by message_id const result = new Map(); for (const [messageId, group] of byMessage) { @@ -165,7 +138,6 @@ function transformComments( }; }); - // Sort by created_at comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); result.set(messageId, comments); } @@ -174,15 +146,12 @@ function transformComments( } /** - * Hook for syncing comments with Electric SQL real-time sync. + * Syncs comments for a thread via Zero 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); @@ -193,13 +162,11 @@ export function useCommentsElectric(threadId: number | null) { 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; @@ -207,12 +174,6 @@ export function useCommentsElectric(threadId: number | null) { queryClientRef.current = queryClient; }, [memberMap, currentUserId, isOwner, queryClient]); - const syncHandleRef = useRef(null); - const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null); - const syncKeyRef = useRef(null); - const streamUpdateDebounceRef = useRef | null>(null); - - // Stable callback that uses refs for fresh values const updateReactQueryCache = useCallback((rows: RawCommentRow[]) => { const commentsByMessage = transformComments( rows, @@ -230,184 +191,22 @@ export function useCommentsElectric(threadId: number | null) { } }, []); + const [data] = useQuery(queries.comments.byThread({ threadId: threadId ?? -1 })); + useEffect(() => { - if (!threadId || !electricClient) { - return; - } + if (!threadId || !data) return; - const syncKey = `comments_${threadId}`; - if (syncKeyRef.current === syncKey) { - return; - } + const rows: RawCommentRow[] = data.map((c) => ({ + id: c.id, + message_id: c.messageId, + thread_id: c.threadId, + parent_id: c.parentId ?? null, + author_id: c.authorId ?? null, + content: c.content, + created_at: String(c.createdAt), + updated_at: String(c.updatedAt), + })); - // 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(); - - // Subscribe to the sync stream for real-time updates from Electric SQL - // This ensures we catch updates even if PGlite live query misses them - if (handle.stream) { - const stream = handle.stream as { - subscribe?: (callback: (messages: unknown[]) => void) => void; - }; - if (typeof stream.subscribe === "function") { - stream.subscribe((messages: unknown[]) => { - if (!mounted) return; - // When Electric sync receives new data, refresh from PGlite - // This handles cases where live query might miss the update - if (messages && messages.length > 0) { - // Debounce the refresh to avoid excessive queries - if (streamUpdateDebounceRef.current) { - clearTimeout(streamUpdateDebounceRef.current); - } - streamUpdateDebounceRef.current = setTimeout(() => { - if (mounted) { - fetchAndUpdateCache(); - } - }, STREAM_UPDATE_DEBOUNCE_MS); - } - }); - } - } - } 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; - - // Clear debounce timeout - if (streamUpdateDebounceRef.current) { - clearTimeout(streamUpdateDebounceRef.current); - streamUpdateDebounceRef.current = null; - } - - if (syncHandleRef.current) { - try { - syncHandleRef.current.unsubscribe(); - } catch { - // PGlite may already be closed during cleanup - } - syncHandleRef.current = null; - } - if (liveQueryRef.current) { - try { - liveQueryRef.current.unsubscribe(); - } catch { - // PGlite may already be closed during cleanup - } - liveQueryRef.current = null; - } - }; - }, [threadId, electricClient, updateReactQueryCache]); + updateReactQueryCache(rows); + }, [threadId, data, updateReactQueryCache]); }