返回 CodeWhale
connection-banner.tsx
根目录 / web / components / connection-banner.tsx
1 "use client";
2
3 import { useCallback, useEffect, useReducer, useRef, useState } from "react";
4 import {
5 INITIAL_CONNECTION_STATE,
6 backoffDelayMs,
7 nextConnectionState,
8 shouldProbe,
9 type ConnectionState,
10 } from "@/lib/connection-state";
11 import { fill, getChrome, getStates } from "@/lib/i18n/dictionaries";
12
13 /**
14 * Offline / reconnect banner for the signed-in shell.
15 *
16 * State meaning lives in lib/connection-state.ts; this component owns the
17 * browser events, the probe, and the timers. The probe is a real request to
18 * a first-party endpoint (`/api/facts`, `Cache-Control: no-store`), so
19 * "back online" means the server answered — never the browser's guess
20 * alone. No data is faked while disconnected: the banner says actions are
21 * paused, and the page underneath keeps whatever it last rendered.
22 */
23 export function ConnectionBanner({
24 locale,
25 probeUrl = "/api/facts",
26 /** Periodic heartbeat while online; 0 disables it. */
27 heartbeatMs = 60_000,
28 }: {
29 locale: string;
30 probeUrl?: string;
31 heartbeatMs?: number;
32 }) {
33 const t = getStates(locale);
34 const chrome = getChrome(locale);
35 const [state, dispatch] = useReducer(nextConnectionState, INITIAL_CONNECTION_STATE);
36 const [dismissedRestored, setDismissedRestored] = useState(false);
37 const timer = useRef<number | null>(null);
38 const inFlight = useRef(false);
39
40 const probe = useCallback(async () => {
41 if (inFlight.current) return;
42 inFlight.current = true;
43 dispatch({ type: "probe-start" });
44 try {
45 const controller = new AbortController();
46 const timeout = window.setTimeout(() => controller.abort(), 8_000);
47 const res = await fetch(probeUrl, {
48 method: "GET",
49 cache: "no-store",
50 signal: controller.signal,
51 headers: { Accept: "application/json" },
52 });
53 window.clearTimeout(timeout);
54 dispatch({ type: res.ok ? "probe-ok" : "probe-failed", at: Date.now() });
55 } catch {
56 dispatch({ type: "probe-failed", at: Date.now() });
57 } finally {
58 inFlight.current = false;
59 }
60 }, [probeUrl]);
61
62 // Browser network events.
63 useEffect(() => {
64 if (typeof navigator !== "undefined" && navigator.onLine === false) {
65 dispatch({ type: "browser-offline" });
66 }
67 const onOnline = () => {
68 dispatch({ type: "browser-online" });
69 void probe();
70 };
71 const onOffline = () => dispatch({ type: "browser-offline" });
72 window.addEventListener("online", onOnline);
73 window.addEventListener("offline", onOffline);
74 return () => {
75 window.removeEventListener("online", onOnline);
76 window.removeEventListener("offline", onOffline);
77 };
78 }, [probe]);
79
80 // Retry schedule while reconnecting/degraded; heartbeat while online.
81 useEffect(() => {
82 if (timer.current !== null) window.clearTimeout(timer.current);
83 timer.current = null;
84 if (shouldProbe(state)) {
85 timer.current = window.setTimeout(() => void probe(), backoffDelayMs(state.attempt));
86 } else if (state.status === "online" && heartbeatMs > 0) {
87 timer.current = window.setTimeout(() => void probe(), heartbeatMs);
88 }
89 return () => {
90 if (timer.current !== null) window.clearTimeout(timer.current);
91 };
92 }, [state, probe, heartbeatMs]);
93
94 // "Back online" shows briefly, then clears itself.
95 useEffect(() => {
96 if (!state.restored) return;
97 setDismissedRestored(false);
98 const id = window.setTimeout(() => dispatch({ type: "restored-seen" }), 4_000);
99 return () => window.clearTimeout(id);
100 }, [state.restored]);
101
102 const view = bannerView(state, dismissedRestored);
103 if (!view) return null;
104
105 const copy = {
106 offline: { title: t.offlineTitle, body: t.offlineBody },
107 reconnecting: {
108 title: t.reconnectingTitle,
109 body: fill(t.reconnectingBody, { attempt: state.attempt + 1 }),
110 },
111 degraded: { title: t.degradedTitle, body: t.degradedBody },
112 restored: { title: t.onlineTitle, body: t.onlineBody },
113 }[view];
114
115 const lastChecked =
116 state.lastCheckedAt !== null
117 ? fill(t.lastChecked, {
118 time: new Date(state.lastCheckedAt).toLocaleTimeString(chrome.dateLocale, {
119 hour: "2-digit",
120 minute: "2-digit",
121 second: "2-digit",
122 }),
123 })
124 : null;
125
126 return (
127 <div
128 className={`connection-banner connection-banner-${view}`}
129 role={view === "offline" ? "alert" : "status"}
130 aria-live={view === "offline" ? "assertive" : "polite"}
131 data-connection={state.status}
132 >
133 <span className="connection-mark" aria-hidden="true" />
134 <div className="connection-copy">
135 <p className="connection-title">{copy.title}</p>
136 <p className="connection-body">
137 {copy.body}
138 {lastChecked && view !== "restored" && (
139 <span className="connection-checked"> · {lastChecked}</span>
140 )}
141 </p>
142 </div>
143 <div className="connection-actions">
144 {view === "restored" ? (
145 <button
146 type="button"
147 className="connection-button"
148 onClick={() => setDismissedRestored(true)}
149 >
150 {t.dismiss}
151 </button>
152 ) : (
153 <button
154 type="button"
155 className="connection-button connection-button-primary"
156 onClick={() => void probe()}
157 disabled={state.status === "offline"}
158 aria-busy={state.status === "reconnecting"}
159 >
160 {t.retryNow}
161 </button>
162 )}
163 </div>
164 </div>
165 );
166 }
167
168 type BannerView = "offline" | "reconnecting" | "degraded" | "restored";
169
170 /** Which banner (if any) a state renders. Exported for tests. */
171 export function bannerView(state: ConnectionState, dismissedRestored: boolean): BannerView | null {
172 if (state.status === "online") {
173 return state.restored && !dismissedRestored ? "restored" : null;
174 }
175 return state.status;
176 }
177
177 lines Plain Text