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