blog2video-test / frontend /src /hooks /useCollabSocket.ts
Muhammad-Mehdi's picture
collaboration changes added
5f07de7
Raw
History Blame Contribute Delete
7.73 kB
import { useCallback, useEffect, useRef, useState } from "react";
import { BACKEND_URL } from "../api/http";
// ─── Message shapes ───────────────────────────────────────
export interface Peer {
user_id: number;
name: string;
picture: string | null;
}
export interface CollabEdit {
scope: "scene" | "project";
scene_id?: number;
field: string;
value: unknown;
user_id: number;
name: string;
change_set_id: string;
}
interface ProjectState {
project: Record<string, unknown>;
scenes: Array<Record<string, unknown> & { id: number }>;
}
export interface CommentEvent {
type: "comment_added" | "comment_deleted";
comment?: { scene_id: number; [k: string]: unknown };
comment_id?: number;
scene_id?: number;
}
interface UseCollabSocketOpts {
projectId: number;
token: string | null;
enabled: boolean;
onEdit?: (edit: CollabEdit) => void;
onComment?: (event: CommentEvent) => void;
/** A bulk job (template change, regen) finished — refetch the whole project. */
onReload?: () => void;
/** The owner revoked this user's access — show a message and leave the project. */
onRevoked?: (message?: string) => void;
}
interface UseCollabSocket {
connected: boolean;
peers: Peer[];
state: ProjectState | null;
/** Push a local field edit; it applies directly to the live video for everyone. */
sendEdit: (edit: {
scope: "scene" | "project";
scene_id?: number;
field: string;
value: unknown;
change_set_id?: string;
}) => void;
/** Advisory soft lock so peers see "X is editing this scene". */
lockScene: (sceneId: number) => void;
unlockScene: (sceneId: number) => void;
}
/**
* Live collaboration socket for a project.
*
* Connects to the backend websocket, tracks presence + the seeded project state,
* applies inbound edits via ``onEdit``, and exposes ``sendEdit`` to broadcast local
* edits (which apply directly to the live video). Reconnects with backoff while
* ``enabled``. No-op (disconnected) when disabled or unauthenticated so single-owner
* projects pay no websocket cost until shared.
*/
export function useCollabSocket({
projectId,
token,
enabled,
onEdit,
onComment,
onReload,
onRevoked,
}: UseCollabSocketOpts): UseCollabSocket {
const [connected, setConnected] = useState(false);
const [peers, setPeers] = useState<Peer[]>([]);
const [state, setState] = useState<ProjectState | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const retryRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closedByUsRef = useRef(false);
// Keep latest callbacks without re-opening the socket.
const cbRef = useRef({ onEdit, onComment, onReload, onRevoked });
cbRef.current = { onEdit, onComment, onReload, onRevoked };
const connect = useCallback(() => {
if (!enabled || !token) return;
console.log("[COLLAB] connect() called, projectId=", projectId, "existing readyState=", wsRef.current?.readyState);
// Don't open a second socket if one is already open or connecting — prevents a
// pile of orphaned server-side connections when connect() runs more than once.
const existing = wsRef.current;
if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
return;
}
closedByUsRef.current = false;
const scheme = BACKEND_URL.startsWith("https") ? "wss" : window.location.protocol === "https:" ? "wss" : "ws";
const host = BACKEND_URL
? BACKEND_URL.replace(/^https?:\/\//, "")
: window.location.host;
const url = `${scheme}://${host}/api/projects/${projectId}/collab?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
retryRef.current = 0;
setConnected(true);
};
ws.onmessage = (ev) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
switch (msg.type) {
case "init":
setState(msg.state as ProjectState);
setPeers((msg.peers as Peer[]) || []);
break;
case "presence":
console.log("[COLLAB] presence", msg.event, "peers:", (msg.peers as Peer[])?.length);
setPeers((msg.peers as Peer[]) || []);
break;
case "edit":
console.log("[COLLAB] received edit", msg);
cbRef.current.onEdit?.(msg as unknown as CollabEdit);
break;
case "comment_added":
case "comment_deleted":
cbRef.current.onComment?.(msg as unknown as CommentEvent);
break;
case "project_reloaded":
cbRef.current.onReload?.();
break;
case "access_revoked":
cbRef.current.onRevoked?.(msg.message as string | undefined);
break;
default:
break;
}
};
ws.onclose = (ev) => {
setConnected(false);
wsRef.current = null;
// 4401/4403/4404 are auth/access closes — don't retry (revoked or no access).
const noRetry = [4401, 4403, 4404].includes(ev.code);
if (!closedByUsRef.current && !noRetry && enabled) {
const delay = Math.min(1000 * 2 ** retryRef.current, 15000);
retryRef.current += 1;
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
retryTimerRef.current = setTimeout(connect, delay);
}
};
ws.onerror = () => {
try {
ws.close();
} catch {
/* noop */
}
};
}, [enabled, token, projectId]);
useEffect(() => {
console.log("[COLLAB] hook effect MOUNT, projectId=", projectId);
connect();
return () => {
console.log("[COLLAB] hook effect CLEANUP, projectId=", projectId);
closedByUsRef.current = true;
// Cancel any pending reconnect so it can't open a socket after unmount.
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
// Tell the server we're leaving so it drops us from presence immediately,
// rather than waiting for the TCP disconnect to surface.
try {
ws.send(JSON.stringify({ type: "leave" }));
} catch {
/* noop */
}
}
ws?.close();
wsRef.current = null;
};
}, [connect]);
// Tab close / hard refresh doesn't reliably run the React cleanup above, so send
// an explicit leave on pagehide too, dropping us from presence for the others.
useEffect(() => {
const onPageHide = () => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(JSON.stringify({ type: "leave" }));
} catch {
/* noop */
}
}
};
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
}, []);
const send = useCallback((payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(payload));
}
}, []);
const sendEdit = useCallback<UseCollabSocket["sendEdit"]>(
(edit) => send({ type: "edit", ...edit }),
[send]
);
const lockScene = useCallback((sceneId: number) => send({ type: "lock", scene_id: sceneId }), [send]);
const unlockScene = useCallback((sceneId: number) => send({ type: "unlock", scene_id: sceneId }), [send]);
return { connected, peers, state, sendEdit, lockScene, unlockScene };
}