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