feat(ui): extract reusable one-shot slow-hint hook; refactor join(3s) and RTC negotiate(8s) hints; share rtcPhase mapper; update docs

This commit is contained in:
david_bai
2025-12-06 11:05:39 +08:00
parent 761921684c
commit 0d830114cd
6 changed files with 185 additions and 152 deletions
+19
View File
@@ -0,0 +1,19 @@
export type Phase = "idle" | "negotiating" | "connected" | "disconnected";
// Normalize various RTC connection states into simplified phases used by UI
export function mapPhase(state?: string): Phase {
if (!state) return "idle";
if (state === "new" || state === "connecting") return "negotiating";
if (state === "connected") return "connected";
if (state === "disconnected" || state === "failed" || state === "closed")
return "disconnected";
// Already normalized values from store
if (
state === "idle" ||
(state as any) === "negotiating" ||
(state as any) === "disconnected"
)
return state as Phase;
return "idle";
}
+95
View File
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef } from "react";
type PutFn = (
message: string,
isShareEnd?: boolean,
displayTimeMs?: number
) => void;
type MessageFactory = () =>
| {
text?: string;
isShareEnd?: boolean;
}
| null;
interface UseOneShotSlowHintOptions {
thresholdMs: number;
putMessageInMs: PutFn;
getMessage: MessageFactory;
visibilityGate?: boolean; // default true: only show when document is visible
displayMs?: number; // default 6000
}
// A small utility hook to manage a one-shot slow-hint timer per attempt.
// - arm(): start a timer if not already started
// - disarm(): clear running timer (does not reset shown flag)
// - reset(): clear timer and reset once-shown & pending flags
export function useOneShotSlowHint({
thresholdMs,
putMessageInMs,
getMessage,
visibilityGate = true,
displayMs = 6000,
}: UseOneShotSlowHintOptions) {
const timerRef = useRef<number | null>(null);
const shownRef = useRef<boolean>(false);
const pendingRef = useRef<boolean>(false);
const disarm = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const reset = useCallback(() => {
disarm();
shownRef.current = false;
pendingRef.current = false;
}, [disarm]);
const fireIfEligible = useCallback(() => {
if (shownRef.current) return;
const payload = getMessage();
if (!payload || !payload.text) return;
if (visibilityGate && typeof document !== "undefined") {
if (document.visibilityState !== "visible") {
pendingRef.current = true;
return;
}
}
putMessageInMs(payload.text, payload.isShareEnd, displayMs);
shownRef.current = true;
pendingRef.current = false;
}, [displayMs, getMessage, putMessageInMs, visibilityGate]);
const arm = useCallback(
(_key?: string) => {
if (timerRef.current) return; // already armed
timerRef.current = window.setTimeout(() => {
fireIfEligible();
}, thresholdMs) as unknown as number;
},
[fireIfEligible, thresholdMs]
);
// Visibility change handling: if pending, try to fire once when visible
useEffect(() => {
if (!visibilityGate) return;
const handler = () => {
if (document.visibilityState !== "visible") return;
if (pendingRef.current && !shownRef.current) {
fireIfEligible();
}
};
document.addEventListener("visibilitychange", handler);
return () => document.removeEventListener("visibilitychange", handler);
}, [fireIfEligible, visibilityGate]);
// Cleanup on unmount
useEffect(() => () => disarm(), [disarm]);
return { arm, disarm, reset } as const;
}