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
+4 -4
View File
@@ -24,16 +24,16 @@
- `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooksuseWebRTCConnection/useFileTransferHandler/useRoomManager/usePageSetup/useClipboardAppMessages),处理全局拖拽事件和双标签页(发送/接收)管理。
- 体验增强:切到接收端(retrieve)且满足“未在房间、URL 无 roomId、输入为空、存在缓存ID”时自动填充并加入房间(读取 `frontend/lib/roomIdCache.ts`)。
- 连接反馈:集成 `useConnectionFeedback``frontend/hooks/useConnectionFeedback.ts`),桥接 WebRTC 连接态到 UI 文案,含协商中提示、8s 慢连接提示、断开/重连/恢复提示(前台可见时提示)。
- 连接反馈:集成 `useConnectionFeedback``frontend/hooks/useConnectionFeedback.ts`),桥接 WebRTC 连接态到 UI 文案,含协商中提示、8s 慢连接提示、断开/重连/恢复提示(前台可见时提示)。慢提示统一复用 `frontend/utils/useOneShotSlowHint.ts`
- `frontend/hooks/` — 业务中枢 Hooks。
- `useRoomManager.ts`
- 入房流程:`join_inProgress`(立即)、`join_slow`3s)、`join_timeout`(15s);join 成功/失败均清理定时器。
- 入房流程:`join_inProgress`(立即)、`join_slow`3s,复用 `useOneShotSlowHint`)、`join_timeout`(15s);join 成功/失败均清理定时器。
- 等效成功信号:在 `joinResponse` 之前若收到 `ready/recipient-ready/offer`,提前判定入房成功并清理 3s/15s 定时器。
- 其他:房间状态文案、分享链接生成、离开房间、输入校验(750ms 防抖)。
- `useConnectionFeedback.ts`
- 状态归一化:`new/connecting``negotiating``failed/closed``disconnected`
- 协商慢提示:8s 定时器(`rtc_slow`),单次协商仅提示一次;若在后台到时则挂起,回到前台且仍协商时补发一次。
- 状态归一化:`new/connecting``negotiating``failed/closed``disconnected`(复用 `utils/rtcPhase.ts`
- 协商慢提示:8s 定时器(`rtc_slow`),单次协商仅提示一次;若在后台到时则挂起,回到前台且仍协商时补发一次(复用 `useOneShotSlowHint`
- 一次性提示:首次 `connected``rtc_connected`)仅提示一次;断开前台重连(`rtc_reconnecting`)与恢复(`rtc_restored`)。
- i18n 文案与类型
+1
View File
@@ -281,6 +281,7 @@ Core Services (webrtcService) + Store (fileTransferStore)
- 状态归一化(mapPhase):`new/connecting``negotiating``failed/closed``disconnected`
- 协商慢提示:8s 定时器、前后台可见性节制、单次协商尝试仅提示一次(含挂起→前台补发)。
- 一次性提示:首次 `connected` 只显示一次;断开→恢复显示 `rtc_restored`;仅前台显示 `rtc_reconnecting`
- 复用:慢提示定时与前后台补发由 `frontend/utils/useOneShotSlowHint.ts` 统一实现;状态归一化由 `frontend/utils/rtcPhase.ts` 提供。
文案与 i18n
- 文案键均位于 `frontend/constants/messages/*.{ts}`,类型定义见 `frontend/types/messages.ts`
+35 -130
View File
@@ -1,25 +1,8 @@
import { useEffect, useRef } from "react";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import type { Messages } from "@/types/messages";
type Phase = "idle" | "negotiating" | "connected" | "disconnected";
const SLOW_RTC_MS = 8000; // 8s threshold for slow P2P negotiation
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";
// store may already map to these values
if (
state === "idle" ||
(state as any) === "negotiating" ||
(state as any) === "disconnected"
)
return state as Phase;
return "idle";
}
import { mapPhase, type Phase } from "@/utils/rtcPhase";
import { useOneShotSlowHint } from "@/utils/useOneShotSlowHint";
interface UseConnectionFeedbackProps {
messages: Messages | null;
@@ -46,12 +29,28 @@ export function useConnectionFeedback({
const wasDiscRecvRef = useRef<boolean>(false);
const sharePhaseRef = useRef<Phase>("idle");
const recvPhaseRef = useRef<Phase>("idle");
// Slow negotiation hint management
const slowTimerShareRef = useRef<number | null>(null);
const slowTimerRecvRef = useRef<number | null>(null);
const slowShownRef = useRef<boolean>(false);
const slowTriggerSideRef = useRef<"share" | "recv" | null>(null);
const slowPendingRef = useRef<boolean>(false);
// Which side first entered negotiating, to infer message side
const rtcSlowTriggerSideRef = useRef<"share" | "recv" | null>(null);
// One-shot slow hint for negotiating ≥ 8s (front-visible, once per attempt)
const { arm: armRtcSlow, disarm: disarmRtcSlow, reset: resetRtcSlow } = useOneShotSlowHint({
thresholdMs: 8000,
putMessageInMs,
displayMs: 6000,
getMessage: () => {
if (!messages) return null;
const text = messages.text.ClipboardApp.rtc_slow;
if (!text) return null;
const isShareEnd =
rtcSlowTriggerSideRef.current === "share"
? true
: rtcSlowTriggerSideRef.current === "recv"
? false
: sharePhaseRef.current === "negotiating";
return { text, isShareEnd };
},
visibilityGate: true,
});
// Bridge RTC connection state changes to UI messages
useEffect(() => {
@@ -67,76 +66,12 @@ export function useConnectionFeedback({
sharePhaseRef.current = nowShare;
recvPhaseRef.current = nowRecv;
// Helper: start slow negotiation timer for a side
const startSlowTimer = (side: "share" | "recv") => {
if (side === "share") {
if (slowTimerShareRef.current) return;
if (!slowTriggerSideRef.current) slowTriggerSideRef.current = "share";
slowTimerShareRef.current = window.setTimeout(() => {
// Only show if still negotiating at timeout
const stillNegotiating =
sharePhaseRef.current === "negotiating" ||
recvPhaseRef.current === "negotiating";
if (!stillNegotiating || slowShownRef.current) return;
if (document.visibilityState !== "visible") {
slowPendingRef.current = true;
return;
}
const msg = messages.text.ClipboardApp.rtc_slow;
if (msg) {
const isShareEnd =
slowTriggerSideRef.current === "share"
? true
: slowTriggerSideRef.current === "recv"
? false
: sharePhaseRef.current === "negotiating";
putMessageInMs(msg, isShareEnd, 6000);
slowShownRef.current = true;
}
}, SLOW_RTC_MS) as unknown as number;
} else {
if (slowTimerRecvRef.current) return;
if (!slowTriggerSideRef.current) slowTriggerSideRef.current = "recv";
slowTimerRecvRef.current = window.setTimeout(() => {
const stillNegotiating =
sharePhaseRef.current === "negotiating" ||
recvPhaseRef.current === "negotiating";
if (!stillNegotiating || slowShownRef.current) return;
if (document.visibilityState !== "visible") {
slowPendingRef.current = true;
return;
}
const msg = messages.text.ClipboardApp.rtc_slow;
if (msg) {
const isShareEnd =
slowTriggerSideRef.current === "share"
? true
: slowTriggerSideRef.current === "recv"
? false
: sharePhaseRef.current === "negotiating";
putMessageInMs(msg, isShareEnd, 6000);
slowShownRef.current = true;
}
}, SLOW_RTC_MS) as unknown as number;
}
};
const clearSlowTimer = (side: "share" | "recv") => {
if (side === "share" && slowTimerShareRef.current) {
clearTimeout(slowTimerShareRef.current);
slowTimerShareRef.current = null;
}
if (side === "recv" && slowTimerRecvRef.current) {
clearTimeout(slowTimerRecvRef.current);
slowTimerRecvRef.current = null;
}
};
// Sender side mapping
if (nowShare === "negotiating" && prevShare !== "negotiating") {
const msg = messages.text.ClipboardApp.rtc_negotiating;
if (msg) putMessageInMs(msg, true, 4000);
startSlowTimer("share");
if (!rtcSlowTriggerSideRef.current) rtcSlowTriggerSideRef.current = "share";
armRtcSlow("rtc-negotiating");
}
if (nowShare === "connected") {
if (!everShareRef.current) {
@@ -149,7 +84,7 @@ export function useConnectionFeedback({
}
everShareRef.current = true;
wasDiscShareRef.current = false;
clearSlowTimer("share");
disarmRtcSlow();
}
if (nowShare === "disconnected") {
const isForeground = document.visibilityState === "visible";
@@ -158,14 +93,15 @@ export function useConnectionFeedback({
if (msg) putMessageInMs(msg, true, 4000);
wasDiscShareRef.current = true;
}
clearSlowTimer("share");
disarmRtcSlow();
}
// Receiver side mapping
if (nowRecv === "negotiating" && prevRecv !== "negotiating") {
const msg = messages.text.ClipboardApp.rtc_negotiating;
if (msg) putMessageInMs(msg, false, 4000);
startSlowTimer("recv");
if (!rtcSlowTriggerSideRef.current) rtcSlowTriggerSideRef.current = "recv";
armRtcSlow("rtc-negotiating");
}
if (nowRecv === "connected") {
if (!everRecvRef.current) {
@@ -178,7 +114,7 @@ export function useConnectionFeedback({
}
everRecvRef.current = true;
wasDiscRecvRef.current = false;
clearSlowTimer("recv");
disarmRtcSlow();
}
if (nowRecv === "disconnected") {
const isForeground = document.visibilityState === "visible";
@@ -187,22 +123,19 @@ export function useConnectionFeedback({
if (msg) putMessageInMs(msg, false, 4000);
wasDiscRecvRef.current = true;
}
clearSlowTimer("recv");
disarmRtcSlow();
}
// If both sides are not negotiating, reset slow hint state for next attempt
if (nowShare !== "negotiating" && nowRecv !== "negotiating") {
slowShownRef.current = false;
slowTriggerSideRef.current = null;
slowPendingRef.current = false;
clearSlowTimer("share");
clearSlowTimer("recv");
resetRtcSlow();
rtcSlowTriggerSideRef.current = null;
}
// Save previous for next comparison
prevShareRef.current = nowShare;
prevRecvRef.current = nowRecv;
}, [messages, shareConnectionState, retrieveConnectionState, putMessageInMs]);
}, [messages, shareConnectionState, retrieveConnectionState, putMessageInMs, armRtcSlow, disarmRtcSlow, resetRtcSlow]);
// Visibility change: when returning to foreground, if still disconnected, hint "reconnecting"
useEffect(() => {
@@ -229,26 +162,6 @@ export function useConnectionFeedback({
if (msg) putMessageInMs(msg, false, 4000);
wasDiscRecvRef.current = true;
}
// If a slow hint was pending while hidden and still negotiating, show it once
if (
slowPendingRef.current &&
!slowShownRef.current &&
(nowShare === "negotiating" || nowRecv === "negotiating")
) {
const msg = messages.text.ClipboardApp.rtc_slow;
if (msg) {
const isShareEnd =
slowTriggerSideRef.current === "share"
? true
: slowTriggerSideRef.current === "recv"
? false
: nowShare === "negotiating";
putMessageInMs(msg, isShareEnd, 6000);
slowShownRef.current = true;
slowPendingRef.current = false;
}
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
@@ -256,12 +169,4 @@ export function useConnectionFeedback({
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [messages, putMessageInMs]);
// Cleanup on unmount: clear any running timers
useEffect(() => {
return () => {
if (slowTimerShareRef.current) clearTimeout(slowTimerShareRef.current);
if (slowTimerRecvRef.current) clearTimeout(slowTimerRecvRef.current);
};
}, []);
}
+31 -18
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { webrtcService } from "@/lib/webrtcService";
import { useFileTransferStore } from "@/stores/fileTransferStore";
import { fetchRoom, createRoom, checkRoom, leaveRoom } from "@/app/config/api";
import { debounce } from "lodash";
import type { Messages } from "@/types/messages";
import { useOneShotSlowHint } from "@/utils/useOneShotSlowHint";
function format_peopleMsg(template: string, peerCount: number) {
return template.replace("{peerCount}", peerCount.toString());
@@ -46,19 +47,31 @@ export function useRoomManager({
resetSenderApp,
} = useFileTransferStore();
// A ref to indicate join side for slow-hint message orientation
const joinSideRef = useRef<boolean>(true);
// One-shot join slow hint (3s), per join attempt
const { arm: armJoinSlow, disarm: disarmJoinSlow, reset: resetJoinSlow } = useOneShotSlowHint({
thresholdMs: 3000,
putMessageInMs,
displayMs: 6000,
getMessage: () => {
if (!messages) return null;
const text = messages.text.ClipboardApp.join_slow;
if (!text) return null;
return { text, isShareEnd: joinSideRef.current };
},
visibilityGate: true,
});
// Join room method - directly use webrtcService
const joinRoom = useCallback(
async (isSenderSide: boolean, roomId: string) => {
if (!messages) return;
// UI: Joining feedback and slow network hint
let slowJoinTimer: any | null = null;
const clearJoinTimers = () => {
if (slowJoinTimer) {
clearTimeout(slowJoinTimer);
slowJoinTimer = null;
}
};
// UI: Joining feedback and slow network hint (one-shot)
joinSideRef.current = isSenderSide;
resetJoinSlow();
try {
// Immediate feedback on click
@@ -66,10 +79,7 @@ export function useRoomManager({
putMessageInMs(joinInProgressMsg, isSenderSide, 6000);
// 3s slow-network hint
slowJoinTimer = setTimeout(() => {
const joinSlowMsg = messages.text.ClipboardApp.join_slow;
putMessageInMs(joinSlowMsg, isSenderSide, 6000);
}, 3000);
armJoinSlow("join");
// If it's the sender side and the room ID is not the initial ID, need to create the room first
if (
@@ -84,7 +94,7 @@ export function useRoomManager({
messages.text.ClipboardApp.joinRoom.DuplicateMsg,
isSenderSide
);
clearJoinTimers();
disarmJoinSlow();
return;
}
setShareRoomId(roomId);
@@ -94,7 +104,7 @@ export function useRoomManager({
` (Create room error)`,
isSenderSide
);
clearJoinTimers();
disarmJoinSlow();
return;
}
}
@@ -121,7 +131,7 @@ export function useRoomManager({
forceInitiatorOnline
);
clearJoinTimers();
disarmJoinSlow();
putMessageInMs(
messages.text.ClipboardApp.joinRoom.successMsg,
isSenderSide,
@@ -147,8 +157,8 @@ export function useRoomManager({
? messages.text.ClipboardApp.join_timeout
: `${messages.text.ClipboardApp.joinRoom.failMsg} ${error.message}`;
}
// Clear joining timers on failure
clearJoinTimers();
// Clear joining slow-hint on failure
disarmJoinSlow();
putMessageInMs(errorMsg, isSenderSide);
}
},
@@ -160,6 +170,9 @@ export function useRoomManager({
shareRoomId,
setShareRoomId,
setShareLink,
armJoinSlow,
disarmJoinSlow,
resetJoinSlow,
]
);
+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;
}