"use client"; import * as React from "react"; import * as Y from "yjs"; import { HocuspocusProvider, WebSocketStatus, type onStatusParameters, type onSyncedParameters, } from "@hocuspocus/provider"; /** Minimal surface used for presence (avoids y-protocols direct import). */ export type AwarenessApi = { getStates(): Map }>; on(event: "change", fn: () => void): void; off(event: "change", fn: () => void): void; }; export type UseCollaborationOptions = { /** Auth token or resolver (session); placeholder used when unset */ token?: string | (() => string | Promise) | null; }; type CacheEntry = { doc: Y.Doc; provider: HocuspocusProvider; refCount: number; }; const collabCache = new Map(); function resolveToken( token: UseCollaborationOptions["token"], ): string | (() => string) | (() => Promise) | null { if (token === undefined || token === null) { return "dev-placeholder-token"; } if (typeof token === "string") { return token; } return async () => { const raw = await Promise.resolve(token()); return typeof raw === "string" ? raw : ""; }; } function acquireEntry( documentName: string, options: UseCollaborationOptions | undefined, ): CacheEntry { let entry = collabCache.get(documentName); if (!entry) { const doc = new Y.Doc(); const provider = new HocuspocusProvider({ name: documentName, document: doc, url: getCollabUrl(), token: resolveToken(options?.token), }); entry = { doc, provider, refCount: 0 }; collabCache.set(documentName, entry); } entry.refCount += 1; return entry; } function releaseEntry(documentName: string) { const entry = collabCache.get(documentName); if (!entry) return; entry.refCount -= 1; if (entry.refCount <= 0) { try { entry.provider.destroy(); } catch { /* ignore */ } try { entry.doc.destroy(); } catch { /* ignore */ } collabCache.delete(documentName); } } /** * WebSocket URL for the collaboration server. * Uses `NEXT_PUBLIC_COLLAB_URL`, then `COLLAB_SERVER_URL`, then ws://localhost:1234. */ /** * Ask the cached provider for this document to open the WebSocket again (e.g. user clicked Retry). */ export function reconnectCollaboration(documentName: string) { const entry = collabCache.get(documentName); const ws = entry?.provider?.configuration?.websocketProvider; if (ws && typeof (ws as { connect?: () => void }).connect === "function") { void (ws as { connect: () => Promise }).connect(); } } export function getCollabUrl(): string { if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL) { return process.env.NEXT_PUBLIC_COLLAB_URL; } if (typeof process !== "undefined" && process.env.COLLAB_SERVER_URL) { return process.env.COLLAB_SERVER_URL; } return "ws://localhost:1234"; } export type CollaborationConnection = { doc: Y.Doc | null; provider: HocuspocusProvider | null; awareness: AwarenessApi | null; isConnected: boolean; isSynced: boolean; wsStatus: WebSocketStatus; error: Error | null; }; /** * Yjs document + Hocuspocus provider for a named document. * Providers are cached per `documentName` and shared across hook instances (ref-counted). */ export function useCollaboration( documentName: string, options?: UseCollaborationOptions, ): CollaborationConnection { const [doc, setDoc] = React.useState(null); const [provider, setProvider] = React.useState(null); const [isConnected, setIsConnected] = React.useState(false); const [isSynced, setIsSynced] = React.useState(false); const [wsStatus, setWsStatus] = React.useState( WebSocketStatus.Disconnected, ); const [error, setError] = React.useState(null); const optionsRef = React.useRef(options); optionsRef.current = options; React.useEffect(() => { if (typeof window === "undefined") return; setError(null); let entry: CacheEntry; try { entry = acquireEntry(documentName, optionsRef.current); } catch (e) { setError(e instanceof Error ? e : new Error(String(e))); setDoc(null); setProvider(null); return; } const { doc: yDoc, provider: hp } = entry; setDoc(yDoc); setProvider(hp); setIsConnected(hp.configuration.websocketProvider.status === WebSocketStatus.Connected); setWsStatus(hp.configuration.websocketProvider.status); setIsSynced(hp.synced); const onStatus = ({ status }: onStatusParameters) => { setWsStatus(status); setIsConnected(status === WebSocketStatus.Connected); }; const onSynced = ({ state }: onSyncedParameters) => { setIsSynced(state); }; hp.on("status", onStatus); hp.on("synced", onSynced); return () => { hp.off("status", onStatus); hp.off("synced", onSynced); releaseEntry(documentName); setDoc(null); setProvider(null); setIsConnected(false); setIsSynced(false); setWsStatus(WebSocketStatus.Disconnected); }; }, [documentName]); return { doc, provider, awareness: (provider?.awareness as AwarenessApi | null) ?? null, isConnected, isSynced, wsStatus, error, }; } export type AwarenessPeer = { clientId: number; name: string; color: string; imageUrl?: string; }; /** Deterministic HSL color from a display name (stable cursor / avatar border). */ export function colorFromUserName(name: string): string { let hash = 0; for (let i = 0; i < name.length; i += 1) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } const hue = Math.abs(hash) % 360; return `hsl(${hue} 65% 45%)`; } /** * Remote peers from Yjs awareness (excludes local client). */ export function useAwarenessPeers( awareness: AwarenessApi | null, localClientId: number | null, ): AwarenessPeer[] { const [peers, setPeers] = React.useState([]); React.useEffect(() => { if (!awareness || localClientId === null) { setPeers([]); return; } const read = () => { const states = awareness.getStates() as Map< number, { user?: { name?: string; color?: string; image?: string } } >; const next: AwarenessPeer[] = []; states.forEach((state, clientId) => { if (clientId === localClientId) return; const u = state.user; if (!u?.name) return; const name = u.name; const color = u.color ?? colorFromUserName(name); next.push({ clientId, name, color, imageUrl: typeof u.image === "string" ? u.image : undefined, }); }); next.sort((a, b) => a.name.localeCompare(b.name)); setPeers(next); }; read(); awareness.on("change", read); return () => { awareness.off("change", read); }; }, [awareness, localClientId]); return peers; }