Skip to content
108 linesCodeBlameRaw
1import { useEffect, useRef, useState } from "react";
2
3import type { ChatLiveEvent } from "@g1t/contracts";
4
5import { backoff } from "../../lib/chat";
6
7/** How long a conversation's socket stays open after leaving it, while the next one opens. */
8const HANDOFF_MS = 1500;
9
10export type LiveState = "connecting" | "open" | "reconnecting";
11
12/**
13 * A conversation's live socket (routes/workspace/chat/live.ts): every event
14 * goes to `onEvent`; a dropped socket comes back on its own, waiting longer
15 * each time up to half a minute, and at once when the tab is shown again or
16 * the network returns. `onReconnect` runs after each return, so the page
17 * can fetch what it missed. `send` writes to the socket when it is open.
18 */
19export function useChatLive(
20 slug: string,
21 channelId: string,
22 onEvent: (event: ChatLiveEvent) => void,
23 onReconnect: () => void,
24): { state: LiveState; send: (message: object) => void } {
25 const [state, setState] = useState<LiveState>("connecting");
26 const socket = useRef<WebSocket | null>(null);
27 const handlers = useRef({ onEvent, onReconnect });
28 handlers.current = { onEvent, onReconnect };
29
30 useEffect(() => {
31 let attempt = 0;
32 let timer: ReturnType<typeof setTimeout> | null = null;
33 let closed = false;
34 let opened = false;
35 const connect = () => {
36 if (closed) return;
37 timer = null;
38 const scheme = location.protocol === "https:" ? "wss:" : "ws:";
39 let ws: WebSocket;
40 try {
41 ws = new WebSocket(`${scheme}//${location.host}/${slug}/-/chat/live?channel=${encodeURIComponent(channelId)}`);
42 } catch {
43 schedule();
44 return;
45 }
46 socket.current = ws;
47 ws.onopen = () => {
48 const again = opened;
49 opened = true;
50 attempt = 0;
51 setState("open");
52 if (again) handlers.current.onReconnect();
53 };
54 ws.onmessage = (message) => {
55 try {
56 handlers.current.onEvent(JSON.parse(String(message.data)) as ChatLiveEvent);
57 } catch {
58 // Not an event this page knows: ignored.
59 }
60 };
61 ws.onclose = () => {
62 if (socket.current === ws) socket.current = null;
63 if (closed) return;
64 setState(opened ? "reconnecting" : "connecting");
65 schedule();
66 };
67 ws.onerror = () => ws.close();
68 };
69 const schedule = () => {
70 if (closed || timer) return;
71 timer = setTimeout(connect, backoff(attempt++));
72 };
73 // Back now, not after the wait: the tab is shown, or the network returned.
74 const now = () => {
75 if (closed || socket.current || document.visibilityState !== "visible") return;
76 if (timer) clearTimeout(timer);
77 timer = null;
78 attempt = 0;
79 connect();
80 };
81 connect();
82 document.addEventListener("visibilitychange", now);
83 window.addEventListener("online", now);
84 return () => {
85 closed = true;
86 if (timer) clearTimeout(timer);
87 document.removeEventListener("visibilitychange", now);
88 window.removeEventListener("online", now);
89 // Switching conversations: the next one's socket opens before this
90 // one closes, so nothing said in between is missed by either (each
91 // channel is its own room). This one stops delivering at once.
92 const old = socket.current;
93 socket.current = null;
94 if (old) {
95 old.onmessage = null;
96 old.onclose = null;
97 old.onerror = null;
98 setTimeout(() => old.close(), HANDOFF_MS);
99 }
100 };
101 }, [slug, channelId]);
102
103 const send = (message: object) => {
104 const ws = socket.current;
105 if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
106 };
107 return { state, send };
108}