web/app/collab/hooks/use-collab-socket.tsblame
View source
0b4b5821"use client";
0b4b5822
0b4b5823import { useEffect, useRef, useState, useCallback } from "react";
0b4b5824import { io, type Socket } from "socket.io-client";
0b4b5825import { useAuth } from "@/lib/auth";
0b4b5826
0b4b5827export interface CollabUser {
0b4b5828 id: string;
0b4b5829 name: string;
0b4b58210 color: string;
0b4b58211 cursor: { x: number; y: number } | null;
0b4b58212 activeTab: string | null;
0b4b58213}
0b4b58214
0b4b58215export interface CollabNote {
0b4b58216 id: string;
0b4b58217 diagramId: string;
0b4b58218 diagramTitle?: string;
0b4b58219 author: string;
0b4b58220 text: string;
0b4b58221 x?: number;
0b4b58222 y?: number;
0b4b58223 targetNode?: string | null;
0b4b58224 timestamp: string;
0b4b58225 editedAt?: string;
0b4b58226}
0b4b58227
0b4b58228interface UseCollabSocketOptions {
0b4b58229 owner: string;
0b4b58230 repo: string;
0b4b58231 enabled?: boolean;
0b4b58232}
0b4b58233
0b4b58234export function useCollabSocket({ owner, repo, enabled = true }: UseCollabSocketOptions) {
0b4b58235 const { token } = useAuth();
0b4b58236 const socketRef = useRef<Socket | null>(null);
0b4b58237 const [connected, setConnected] = useState(false);
0b4b58238 const [users, setUsers] = useState<Record<string, CollabUser>>({});
0b4b58239 const [notes, setNotes] = useState<Record<string, CollabNote[]>>({});
0b4b58240
0b4b58241 // Event callbacks — set by consumers
0b4b58242 const onCursorUpdatedRef = useRef<((data: any) => void) | null>(null);
0b4b58243 const onCursorRemovedRef = useRef<((data: any) => void) | null>(null);
0b4b58244 const onDiagramCodeRef = useRef<((data: any) => void) | null>(null);
0b4b58245 const onYjsSyncRef = useRef<((data: any) => void) | null>(null);
0b4b58246 const onYjsUpdateRef = useRef<((data: any) => void) | null>(null);
0b4b58247
0b4b58248 useEffect(() => {
0b4b58249 if (!enabled || !token || !owner || !repo) return;
0b4b58250
0b4b58251 // Socket.IO server runs as a co-located process. In production,
0b4b58252 // Caddy routes /socket.io/* to the socket port on the same origin.
0b4b58253 // In dev, use NEXT_PUBLIC_COLLAB_SOCKET_URL (e.g. http://localhost:3334).
0b4b58254 const collabUrl =
0b4b58255 process.env.NEXT_PUBLIC_COLLAB_SOCKET_URL || window.location.origin;
0b4b58256
0b4b58257 const socket = io(`${collabUrl}/collab`, {
0b4b58258 auth: { token },
0b4b58259 transports: ["polling", "websocket"],
0b4b58260 });
0b4b58261
0b4b58262 socketRef.current = socket;
0b4b58263
0b4b58264 socket.on("connect", () => {
0b4b58265 setConnected(true);
0b4b58266 socket.emit("join-room", { owner, repo });
0b4b58267 });
0b4b58268
0b4b58269 socket.on("disconnect", () => setConnected(false));
0b4b58270
0b4b58271 socket.on("room-state", (data: { notes: Record<string, CollabNote[]>; users: Record<string, CollabUser> }) => {
0b4b58272 setNotes(data.notes);
0b4b58273 setUsers(data.users);
0b4b58274 });
0b4b58275
0b4b58276 socket.on("users-updated", (data: Record<string, CollabUser>) => {
0b4b58277 setUsers(data);
0b4b58278 });
0b4b58279
0b4b58280 socket.on("note-added", (note: CollabNote) => {
0b4b58281 setNotes((prev) => ({
0b4b58282 ...prev,
0b4b58283 [note.diagramId]: [...(prev[note.diagramId] ?? []), note],
0b4b58284 }));
0b4b58285 });
0b4b58286
0b4b58287 socket.on("note-edited", ({ noteId, diagramId, text, editedAt }: { noteId: string; diagramId: string; text: string; editedAt: string }) => {
0b4b58288 setNotes((prev) => ({
0b4b58289 ...prev,
0b4b58290 [diagramId]: (prev[diagramId] ?? []).map((n) =>
0b4b58291 n.id === noteId ? { ...n, text, editedAt } : n
0b4b58292 ),
0b4b58293 }));
0b4b58294 });
0b4b58295
0b4b58296 socket.on("note-deleted", ({ noteId, diagramId }: { noteId: string; diagramId: string }) => {
0b4b58297 setNotes((prev) => ({
0b4b58298 ...prev,
0b4b58299 [diagramId]: (prev[diagramId] ?? []).filter((n) => n.id !== noteId),
0b4b582100 }));
0b4b582101 });
0b4b582102
0b4b582103 socket.on("cursor-updated", (data: any) => onCursorUpdatedRef.current?.(data));
0b4b582104 socket.on("cursor-removed", (data: any) => onCursorRemovedRef.current?.(data));
0b4b582105 socket.on("diagram-code", (data: any) => onDiagramCodeRef.current?.(data));
0b4b582106 socket.on("yjs-sync", (data: any) => onYjsSyncRef.current?.(data));
0b4b582107 socket.on("yjs-update", (data: any) => onYjsUpdateRef.current?.(data));
0b4b582108
0b4b582109 return () => {
0b4b582110 socket.disconnect();
0b4b582111 socketRef.current = null;
0b4b582112 setConnected(false);
0b4b582113 };
0b4b582114 }, [enabled, token, owner, repo]);
0b4b582115
0b4b582116 const emit = useCallback((event: string, data?: any) => {
0b4b582117 socketRef.current?.emit(event, data);
0b4b582118 }, []);
0b4b582119
0b4b582120 return {
0b4b582121 socket: socketRef,
0b4b582122 connected,
0b4b582123 users,
0b4b582124 notes,
0b4b582125 emit,
0b4b582126 onCursorUpdated: onCursorUpdatedRef,
0b4b582127 onCursorRemoved: onCursorRemovedRef,
0b4b582128 onDiagramCode: onDiagramCodeRef,
0b4b582129 onYjsSync: onYjsSyncRef,
0b4b582130 onYjsUpdate: onYjsUpdateRef,
0b4b582131 };
0b4b582132}