"use client"; /** * usePresence / useDatabasePresence (TASK-0093) — wire this tab's presence * state to the realtime socket for the active page OR database. * * The emission is ROOM-AGNOSTIC: the {@link PresenceEmitter} sends * `presence` frames carrying only `{cursor, viewing}` (identity is * server-stamped, and the server routes each frame to the socket's LAST * subscribed `page:`/`database:` channel). So the same emitter * lifecycle serves both the page route and the database home — only the * CHANNEL SUBSCRIPTION + the STORE KEY differ (spec R6: "the machinery is * identical"). * * Emission lifecycle (shared): * - the caret is read from the presence caret store (stores/presence-store) * — the focused Tiptap editor writes it, every blur clears it. * - `viewing` follows `document.visibilityState` (pagehide → the cursor * hides immediately; pageshow → it comes back). * - on unmount (navigation / tab close) the caret store is cleared, the * emitter disposed, and an explicit `presence-leave` sent (the < 500 ms * leave fast path; the server also drops the peer on the socket close). * * `usePresence` (the page route layout, next to `usePageRealtime`): * emission only — the page's bar reads presence from `usePageRealtime`. * `useDatabasePresence` (the database home): subscribes the `database:` * channel, runs the same emission, and returns the database's roster from * the store (the avatars-only bar — spec R6, no cursors in the table cells). */ import { useEffect, useMemo } from "react"; import { authStore } from "@/lib/auth-store"; import { channelForPage, realtime } from "@/lib/realtime"; import { stripRoomKey } from "./realtime-frame-handler"; import { PresenceEmitter } from "@/lib/presence-emitter"; import { useBlockPatchStore } from "@/stores/block-patch-store"; import { usePresenceCaretStore } from "@/stores/presence-store"; import type { Presence, RealtimeServerEvent } from "@/lib/types"; const EMPTY_PRESENCE: Record = {}; /** The shared emitter lifecycle: caret store + visibility + leave on * unmount. `active` gates it (a null page id → no emission). */ function usePresenceEmitter(active: boolean): void { useEffect(() => { if (!active) return; const emitter = new PresenceEmitter({ send: (s) => realtime.sendPresence(s) }); const offCaret = usePresenceCaretStore.subscribe((state) => emitter.setCaret(state.caret)); emitter.setCaret(usePresenceCaretStore.getState().caret); emitter.setViewing( typeof document === "undefined" ? true : document.visibilityState === "visible", ); const onVisibility = (): void => { emitter.setViewing(document.visibilityState === "visible"); }; document.addEventListener("visibilitychange", onVisibility); // Re-assert on (re)connect: the client's sendPresence no-ops while the // socket isn't OPEN, so the initial frame (sent pre-connect) is dropped // and would otherwise leave this tab's avatar hidden until the next // caret move. `assert()` re-sends the current state immediately. const offStatus = realtime.onStatus((status) => { if (status === "connected") emitter.assert(); }); return () => { document.removeEventListener("visibilitychange", onVisibility); offCaret(); offStatus(); emitter.dispose(); usePresenceCaretStore.getState().setCaret(null); realtime.sendPresenceLeave(); // immediate leave (fast path). }; }, [active]); } /** Page-route presence emission. The bar reads presence from * `usePageRealtime` (same store). Call from the page layout. */ export function usePresence(pageId: string | null): void { usePresenceEmitter(pageId !== null); } /** Read the page's roster from the store (no subscription — the page route * layout already runs `usePageRealtime`, which subscribes + folds the * frames). Used by the PageView header/overlay. */ export function usePagePresence(pageId: string | null): Presence[] { const presenceMap = useBlockPatchStore((s) => pageId ? (s.presence[pageId] ?? EMPTY_PRESENCE) : EMPTY_PRESENCE, ); return useMemo(() => Object.values(presenceMap), [presenceMap]); } export interface DatabasePresence { presence: Presence[]; } /** Database-home presence (spec R6): the same machinery as the page, but * over the `database:` channel + the database's store key. Returns the * OTHER peers (self excluded) for the header's avatars-only PresenceBar. * * The database home has no block overlay, so it registers ONLY the * presence frame listeners (the full `handlePageRealtimeFrame` folds the * block frames too — unnecessary here). */ export function useDatabasePresence(databaseId: string | null): DatabasePresence { usePresenceEmitter(databaseId !== null); useEffect(() => { if (!databaseId) return; // Open the socket (idempotent) — the database home has no page layout to // connect it (the page route does this in usePageRealtime). realtime.connect(() => authStore.accessToken); const channel = `database:${databaseId}`; realtime.subscribe(channel); const store = useBlockPatchStore.getState(); const onFrame = (event: RealtimeServerEvent): void => { if (event.type === "presence") { store.upsertPresence(stripRoomKey(event.pageId), { userId: event.user.id, name: event.user.name, color: event.user.color, cursor: event.cursor ?? null, viewing: event.viewing, lastSeen: event.lastSeen, }); } else if (event.type === "presence-leave") { store.removePresence(stripRoomKey(event.pageId), event.userId); } }; const offs = [realtime.on("presence", onFrame), realtime.on("presence-leave", onFrame)]; return () => { for (const off of offs) off(); realtime.unsubscribe(channel); // Drop the database's roster (page unmount). useBlockPatchStore.getState().reset(databaseId); }; }, [databaseId]); const presenceMap = useBlockPatchStore((s) => databaseId ? (s.presence[databaseId] ?? EMPTY_PRESENCE) : EMPTY_PRESENCE, ); return useMemo(() => ({ presence: Object.values(presenceMap) }), [presenceMap]); } // Re-export for callers that build the page channel key. export { channelForPage };