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