"use client"; /** * Page-level realtime wiring: subscribe to `page:` on the shared socket * (lib/realtime.ts), fold frames into the block-patch overlay store, and * expose presence. The frame → overlay mapping (incl. the (b) * `block:changed` refetch-hint) lives in `realtime-frame-handler.ts` — * this hook only wires it to the store + query client. * * The overlay keeps the editor responsive (<200 ms, PRODUCT-SPEC §26) while * TanStack Query remains the source of truth at rest — after a refetch the * overlay for the page is reset. */ import { useEffect, useMemo, useState } from "react"; const EMPTY_PRESENCE: Record = {}; import { useQueryClient } from "@tanstack/react-query"; import { authStore } from "@/lib/auth-store"; import { channelForPage, realtime, type RealtimeStatus } from "@/lib/realtime"; import { useBlockPatchStore } from "@/stores/block-patch-store"; import type { Block, BlockPatch, Presence } from "@/lib/types"; import { handlePageRealtimeFrame } from "./realtime-frame-handler"; export interface PageRealtime { status: RealtimeStatus; presence: Presence[]; } export function usePageRealtime(pageId: string | null): PageRealtime { const [status, setStatus] = useState(realtime.connectionStatus); // The store keeps the roster as a userId → peer map (the fold of the // per-peer `presence` frames); expose it as a list. const presenceMap = useBlockPatchStore((s) => pageId ? (s.presence[pageId] ?? EMPTY_PRESENCE) : EMPTY_PRESENCE, ); const presence = useMemo(() => Object.values(presenceMap), [presenceMap]); const qc = useQueryClient(); useEffect(() => { const offStatus = realtime.onStatus(setStatus); // Connect lazily — the first page with realtime opens the socket; it // stays open for the tab's lifetime. realtime.connect(() => authStore.accessToken); if (!pageId) return; const channel = channelForPage(pageId); realtime.subscribe(channel); // All page frames fold through the pure handler (realtime-frame-handler); // the actions below bind it to the overlay store + the query client. The // client emitter keys listeners by exact frame type, so register the // handler for each block-overlay frame it must handle. const actions = { upsert: (pid: string, blocks: Block[]) => useBlockPatchStore.getState().upsert(pid, blocks), delete: (pid: string, blockIds: string[]) => useBlockPatchStore.getState().delete(pid, blockIds), patch: (pid: string, blockId: string, p: BlockPatch) => useBlockPatchStore.getState().patch(pid, blockId, p), upsertPresence: (pid: string, peer: Presence) => useBlockPatchStore.getState().upsertPresence(pid, peer), removePresence: (pid: string, userId: string) => useBlockPatchStore.getState().removePresence(pid, userId), clearBlocks: (pid: string) => useBlockPatchStore.getState().clearBlocks(pid), invalidateBlocks: (pid: string) => qc.invalidateQueries({ queryKey: ["pages", pid, "blocks"] }), }; const frameTypes = [ "presence", "presence-leave", "block:upsert", "block:delete", "block:move", "block:changed", ] as const; const offs = frameTypes.map((t) => realtime.on(t, (event) => handlePageRealtimeFrame(event, actions)), ); return () => { for (const off of offs) off(); offStatus(); realtime.unsubscribe(channel); // Drop the overlay once the page is unmounted; the query cache keeps // its own data and a fresh mount refetches if stale. useBlockPatchStore.getState().reset(pageId); }; }, [pageId, qc]); return useMemo(() => ({ status, presence }), [status, presence]); }