import type { CollabTokenResponse } from '@dorfteich/shared'; import { HocuspocusProvider } from '@hocuspocus/provider'; import { useCallback, useEffect, useRef, useState } from 'react'; import { IndexeddbPersistence } from 'y-indexeddb'; import * as Y from 'yjs'; import { ApiError, apiGet } from '../lib/api'; /** What the editor shows about the live connection (issue #36). */ export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline'; export interface CollabState { provider: HocuspocusProvider | null; status: ConnectionStatus; /** Access level of the current token; `ro` clients cannot edit. */ mode: 'rw' | 'ro' | null; /** Set once the server rejects an update for exceeding the size ceiling (#35). */ tooLarge: boolean; /** True when there are edits held only on this device (offline, #38). */ localOnly: boolean; /** * Set when the api refuses a collaboration token (403/404) on (re)connect, * i.e. edit access was revoked (issue #39). The local content stays visible * so the user can export it; live sync stops. */ accessRevoked: boolean; /** Discard the device-local copy of this page (clears IndexedDB, #39). */ discardLocal: () => Promise; } /** IndexedDB database name for a page's local Yjs persistence (issue #38). */ function localDbName(pageId: string): string { return `dorfteich-page-${pageId}`; } /** WebSocket endpoint of the collab server, behind the same origin as the app * (the reverse proxy forwards `/collab`; deployment.md requires WS upgrade). */ function collabWsUrl(): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${window.location.host}/collab`; } /** * Binds a page's `Y.Doc` to the Hocuspocus collaboration server (ADR 0003, * realtime-collaboration.md). The document loads and persists through the * collab server now — there is no REST autosave. The collaboration token is * fetched lazily on every (re)connect, so an expired token is replaced * transparently and a permission change takes effect on the next reconnect. */ export function useCollabProvider(ydoc: Y.Doc | null, pageId: string): CollabState { const [provider, setProvider] = useState(null); const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>( 'connecting', ); const [synced, setSynced] = useState(false); const [everConnected, setEverConnected] = useState(false); const [online, setOnline] = useState(() => navigator.onLine); const [mode, setMode] = useState<'rw' | 'ro' | null>(null); const [tooLarge, setTooLarge] = useState(false); const [hasUnsynced, setHasUnsynced] = useState(false); const [accessRevoked, setAccessRevoked] = useState(false); // Held so `discardLocal` can clear the current page's IndexedDB store even // after `accessRevoked` has stopped live sync (issue #39). const localPersistenceRef = useRef(null); useEffect(() => { if (!ydoc) return; let disposed = false; // Whether the server has ever confirmed our state this session; decides // whether the local copy is discarded on leave (below). let serverSynced = false; // Local-first persistence: every opened page is mirrored to IndexedDB so // edits survive a reload and offline work (ADR 0003, #38). The provider // and IndexedDB share the same Y.Doc and merge conflict-free. const localPersistence = new IndexeddbPersistence(localDbName(pageId), ydoc); localPersistenceRef.current = localPersistence; setAccessRevoked(false); const instance = new HocuspocusProvider({ url: collabWsUrl(), name: pageId, document: ydoc, token: async () => { try { const response = await apiGet(`/pages/${pageId}/collab-token`); if (!disposed) { setMode(response.mode); setAccessRevoked(false); } return response.token; } catch (error) { // A refused token (403/404) means edit access was revoked while we // were connected or offline (#39). Surface it so the editor can offer // an export; the effect below then stops the reconnect loop. Rethrow // so this connection attempt aborts rather than using a stale token. if (error instanceof ApiError && (error.status === 403 || error.status === 404)) { if (!disposed) setAccessRevoked(true); } throw error; } }, onStatus: ({ status }) => { if (disposed) return; setWsStatus(status); if (status === 'connected') setEverConnected(true); }, onSynced: () => { serverSynced = true; if (!disposed) setSynced(true); }, onDisconnect: () => { if (!disposed) setSynced(false); }, onUnsyncedChanges: ({ number }) => { if (!disposed) setHasUnsynced(number > 0); }, onStateless: ({ payload }) => { if (disposed) return; try { const message = JSON.parse(payload) as { type?: string; code?: string }; if (message.type === 'error' && message.code === 'page_document_too_large') { setTooLarge(true); } } catch { // Ignore malformed stateless payloads. } }, }); setProvider(instance); return () => { disposed = true; instance.destroy(); // Discard the local copy once the server has our changes (bounds // IndexedDB growth); otherwise keep it so offline edits survive to the // next visit (realtime-collaboration.md §Offline). if (serverSynced) { void localPersistence.clearData(); } else { void localPersistence.destroy(); } if (localPersistenceRef.current === localPersistence) localPersistenceRef.current = null; setProvider(null); setSynced(false); setHasUnsynced(false); }; }, [ydoc, pageId]); // Once access is revoked, stop the reconnect loop: every reconnect would only // fetch another refused token and hammer the api (issue #39). The local copy // stays intact for export until the user leaves or explicitly discards it. useEffect(() => { if (accessRevoked && provider) provider.disconnect(); }, [accessRevoked, provider]); useEffect(() => { const goOnline = (): void => setOnline(true); const goOffline = (): void => setOnline(false); window.addEventListener('online', goOnline); window.addEventListener('offline', goOffline); return () => { window.removeEventListener('online', goOnline); window.removeEventListener('offline', goOffline); }; }, []); let status: ConnectionStatus; if (!online) { status = 'offline'; } else if (wsStatus === 'connected' && synced) { status = 'connected'; } else if (everConnected) { status = 'reconnecting'; } else { status = 'connecting'; } // Local-only = we hold edits the server has not acknowledged and we are not // currently in sync (offline or reconnecting). const localOnly = hasUnsynced && status !== 'connected'; const discardLocal = useCallback(async (): Promise => { await localPersistenceRef.current?.clearData(); }, []); return { provider, status, mode, tooLarge, localOnly, accessRevoked, discardLocal }; }