|
1 | | -import { useEffect, useRef } from "react"; |
2 | | -import { io, Socket } from "socket.io-client"; |
3 | | -/* eslint-disable @typescript-eslint/no-explicit-any */ |
4 | | -interface UseDocumentSocketParams { |
5 | | - workspaceId: string; |
6 | | - documentId: string; |
7 | | - onContentChange: (content: string) => void; |
8 | | - onCursorChange: (cursor: any) => void; |
9 | | -} |
| 1 | +import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | +import type { |
| 3 | + ContentChangePayload, |
| 4 | + CursorChangePayload, |
| 5 | + PresencePayload, |
| 6 | +} from "../types/realtime"; |
| 7 | +import { createSocket, disconnectSocket, getSocket } from "../lib/socket"; |
| 8 | + |
| 9 | +export function useDocumentSocket(options: { |
| 10 | + workspaceId?: string | null; |
| 11 | + documentId?: string | undefined; |
| 12 | + onRemoteContent: (payload: ContentChangePayload) => void; |
| 13 | + onRemoteCursor?: (payload: CursorChangePayload) => void; |
| 14 | +}) { |
| 15 | + const { workspaceId, documentId, onRemoteContent, onRemoteCursor } = options; |
10 | 16 |
|
11 | | -export const useDocumentSocket = ({ |
12 | | - workspaceId, |
13 | | - documentId, |
14 | | - onContentChange, |
15 | | - onCursorChange, |
16 | | -}: UseDocumentSocketParams) => { |
17 | | - const socketRef = useRef<Socket | null>(null); |
| 17 | + //maintaing a list of online user.... |
| 18 | + const [onlineUsers, setOnlineUsers] = useState<string[]>([]); |
| 19 | + // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 20 | + const socketRef = useRef<any>(null); |
18 | 21 |
|
| 22 | + // throttle state to 200ms.... |
| 23 | + const lastSentAt = useRef<number>(0); |
| 24 | + const pendingContent = useRef<string | null>(null); |
| 25 | + const timerRef = useRef<number | null>(null); |
| 26 | + const THROTTLE_MS = 200; |
| 27 | + |
| 28 | + //connect and attach lstners.. |
19 | 29 | useEffect(() => { |
20 | | - if (!workspaceId || !documentId) return; |
21 | | - const socket = io("http://localhost:3000", { |
22 | | - transports: ["websocket"], |
23 | | - withCredentials: true, |
24 | | - query: { |
25 | | - workspaceId, |
26 | | - documentId, |
27 | | - }, |
28 | | - }); |
| 30 | + if (!workspaceId || documentId) return; |
29 | 31 |
|
| 32 | + const WS_URL = import.meta.env.VITE_API_URL |
| 33 | + ? (import.meta.env.VITE_API_URL as string).replace(/\/api.*$/, "") |
| 34 | + : "http://localhost:3000"; |
| 35 | + |
| 36 | + const socket = createSocket(WS_URL, { workspaceId, documentId }); |
30 | 37 | socketRef.current = socket; |
31 | 38 |
|
32 | | - //listning to the sockets.... |
33 | | - socket.on("contentChange", (data) => { |
34 | | - onContentChange(data.content); |
| 39 | + //connection logs.... |
| 40 | + |
| 41 | + socket.on("connect", () => { |
| 42 | + console.log("[socket] connected", socket.id); |
35 | 43 | }); |
36 | | - socket.on("cursorChange", (data) => { |
37 | | - onCursorChange?.(data); |
| 44 | + socket.on("connect_error", (err) => { |
| 45 | + console.warn("[socket] connect_error", err?.message ?? err); |
38 | 46 | }); |
39 | | - socket.on("userJoined", (data) => { |
40 | | - console.log("User Joined:", data); |
| 47 | + socket.on("disconnect", (reason) => { |
| 48 | + console.log("[socket] disconnected", reason); |
41 | 49 | }); |
42 | | - socket.on("userLeft", (data) => { |
43 | | - console.log("User Left:", data); |
| 50 | + |
| 51 | + socket.on("contentChange", (payload: ContentChangePayload) => { |
| 52 | + if (!payload || typeof payload.content !== "string") return; |
| 53 | + onRemoteContent(payload); |
44 | 54 | }); |
45 | | - return () => { |
46 | | - socket.disconnect(); |
47 | | - }; |
48 | | - // eslint-disable-next-line react-hooks/exhaustive-deps |
49 | | - }, [workspaceId, documentId]); |
50 | | - |
51 | | - //send updates .. |
52 | | - const sendDocumentUpdate = (newContent: string) => { |
53 | | - socketRef.current?.emit("documentUpdate", { |
54 | | - documentId, |
55 | | - newContent, |
| 55 | + |
| 56 | + socket.on("cursorChange", (payload: CursorChangePayload) => { |
| 57 | + if (!payload) return; |
| 58 | + onRemoteCursor?.(payload); |
56 | 59 | }); |
57 | | - }; |
58 | 60 |
|
59 | | - const sendCursorUpdate = (position: number) => { |
60 | | - socketRef.current?.emit("cursorUpdate", { |
61 | | - documentId, |
62 | | - position, |
| 61 | + socket.on("userJoined", (payload: PresencePayload) => { |
| 62 | + if (!payload?.userId) return; |
| 63 | + setOnlineUsers((prev) => { |
| 64 | + if (prev.includes(payload.userId)) return prev; |
| 65 | + return [...prev, payload.userId]; |
| 66 | + }); |
63 | 67 | }); |
64 | | - }; |
65 | 68 |
|
66 | | - return { sendDocumentUpdate, sendCursorUpdate }; |
67 | | -}; |
| 69 | + socket.on("userLeft", (payload: PresencePayload) => { |
| 70 | + if (!payload?.userId) return; |
| 71 | + setOnlineUsers((prev) => prev.filter((u) => u !== payload.userId)); |
| 72 | + }); |
| 73 | + |
| 74 | + return () => { |
| 75 | + // cleanup: stop listeners and disconnect if this was the only usage |
| 76 | + socket.off("contentChange"); |
| 77 | + socket.off("cursorChange"); |
| 78 | + socket.off("userJoined"); |
| 79 | + socket.off("userLeft"); |
| 80 | + }; |
| 81 | + }, [workspaceId, documentId, onRemoteContent, onRemoteCursor]); |
| 82 | + |
| 83 | + const safeDisconnect = useCallback(() => { |
| 84 | + // remove all listeners and disconnect |
| 85 | + const s = getSocket(); |
| 86 | + if (s) { |
| 87 | + s.off("contentChange"); |
| 88 | + s.off("cursorChange"); |
| 89 | + s.off("userJoined"); |
| 90 | + s.off("userLeft"); |
| 91 | + } |
| 92 | + disconnectSocket(); |
| 93 | + socketRef.current = null; |
| 94 | + setOnlineUsers([]); |
| 95 | + }, []); |
| 96 | + |
| 97 | + // Throttled document update: coalesce updates and send at most once/200ms |
| 98 | + const sendDocumentUpdate = useCallback( |
| 99 | + (newContent: string) => { |
| 100 | + const s = getSocket(); |
| 101 | + if (!s) return; |
| 102 | + |
| 103 | + const now = Date.now(); |
| 104 | + const since = now - (lastSentAt.current || 0); |
| 105 | + |
| 106 | + if (since >= THROTTLE_MS) { |
| 107 | + lastSentAt.current = now; |
| 108 | + s.emit("documentUpdate", { documentId, newContent }); |
| 109 | + // clear pending |
| 110 | + if (timerRef.current) { |
| 111 | + window.clearTimeout(timerRef.current); |
| 112 | + timerRef.current = null; |
| 113 | + } |
| 114 | + pendingContent.current = null; |
| 115 | + return; |
| 116 | + } |
| 117 | + |
| 118 | + // otherwise schedule latest content to be sent later |
| 119 | + pendingContent.current = newContent; |
| 120 | + if (timerRef.current) return; |
| 121 | + |
| 122 | + const wait = THROTTLE_MS - since; |
| 123 | + timerRef.current = window.setTimeout(() => { |
| 124 | + const latest = pendingContent.current; |
| 125 | + if (latest != null) { |
| 126 | + const sock = getSocket(); |
| 127 | + sock?.emit("documentUpdate", { documentId, newContent: latest }); |
| 128 | + lastSentAt.current = Date.now(); |
| 129 | + } |
| 130 | + pendingContent.current = null; |
| 131 | + timerRef.current = null; |
| 132 | + }, wait) as unknown as number; |
| 133 | + }, |
| 134 | + [documentId] |
| 135 | + ); |
| 136 | + |
| 137 | + const sendCursorUpdate = useCallback( |
| 138 | + (position: number) => { |
| 139 | + const s = getSocket(); |
| 140 | + if (!s) return; |
| 141 | + s.emit("cursorUpdate", { documentId, position }); |
| 142 | + }, |
| 143 | + [documentId] |
| 144 | + ); |
| 145 | + |
| 146 | + return { |
| 147 | + sendDocumentUpdate, |
| 148 | + sendCursorUpdate, |
| 149 | + onlineUsers, |
| 150 | + safeDisconnect, |
| 151 | + }; |
| 152 | +} |
0 commit comments