diff --git a/docs/ai-playbook/code-map.zh-CN.md b/docs/ai-playbook/code-map.zh-CN.md index eb74595..ab76fef 100644 --- a/docs/ai-playbook/code-map.zh-CN.md +++ b/docs/ai-playbook/code-map.zh-CN.md @@ -24,16 +24,16 @@ - `frontend/components/ClipboardApp.tsx` — 顶层 UI 协调器,集成 5 个业务 hooks(useWebRTCConnection/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 文案与类型 diff --git a/docs/ai-playbook/flows.zh-CN.md b/docs/ai-playbook/flows.zh-CN.md index 397c4f2..041a88b 100644 --- a/docs/ai-playbook/flows.zh-CN.md +++ b/docs/ai-playbook/flows.zh-CN.md @@ -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`。 diff --git a/frontend/hooks/useConnectionFeedback.ts b/frontend/hooks/useConnectionFeedback.ts index 5fff5f1..835bc51 100644 --- a/frontend/hooks/useConnectionFeedback.ts +++ b/frontend/hooks/useConnectionFeedback.ts @@ -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(false); const sharePhaseRef = useRef("idle"); const recvPhaseRef = useRef("idle"); - // Slow negotiation hint management - const slowTimerShareRef = useRef(null); - const slowTimerRecvRef = useRef(null); - const slowShownRef = useRef(false); - const slowTriggerSideRef = useRef<"share" | "recv" | null>(null); - const slowPendingRef = useRef(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); - }; - }, []); } diff --git a/frontend/hooks/useRoomManager.ts b/frontend/hooks/useRoomManager.ts index 3692fb2..9cd8fb4 100644 --- a/frontend/hooks/useRoomManager.ts +++ b/frontend/hooks/useRoomManager.ts @@ -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(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, ] ); diff --git a/frontend/utils/rtcPhase.ts b/frontend/utils/rtcPhase.ts new file mode 100644 index 0000000..aafe286 --- /dev/null +++ b/frontend/utils/rtcPhase.ts @@ -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"; +} + diff --git a/frontend/utils/useOneShotSlowHint.ts b/frontend/utils/useOneShotSlowHint.ts new file mode 100644 index 0000000..ea11910 --- /dev/null +++ b/frontend/utils/useOneShotSlowHint.ts @@ -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(null); + const shownRef = useRef(false); + const pendingRef = useRef(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; +} +