feat(ui): add 8s P2P slow-connect hint + i18n; document full connection feedback flows
- Hook (useConnectionFeedback):
- Add SLOW_RTC_MS=8000 timer when entering negotiating
- Foreground-only; pending while hidden; show once per negotiation attempt
- Clear timers on connect/disconnect; reset attempt flags when leaving negotiating
- Cleanup timers on unmount
- i18n:
- Add required key ClipboardApp.rtc_slow to types
- Provide translations for zh, en, ja, es, de, fr, ko
- Docs:
- flows.zh-CN: add UI connection feedback state machine covering
join_inProgress (immediate), join_slow (3s), join_timeout (15s),
rtc_negotiating, rtc_slow (8s), rtc_connected, rtc_reconnecting, rtc_restored;
document equivalent success signals and visibility gating
- code-map.zh-CN: outline responsibilities/locations for useRoomManager (join slow/timeout)
and useConnectionFeedback (negotiation slow, reconnect/restored)
This commit is contained in:
@@ -24,6 +24,21 @@
|
||||
|
||||
- `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 慢连接提示、断开/重连/恢复提示(前台可见时提示)。
|
||||
|
||||
- `frontend/hooks/` — 业务中枢 Hooks。
|
||||
- `useRoomManager.ts`
|
||||
- 入房流程:`join_inProgress`(立即)、`join_slow`(3s)、`join_timeout`(15s);join 成功/失败均清理定时器。
|
||||
- 等效成功信号:在 `joinResponse` 之前若收到 `ready/recipient-ready/offer`,提前判定入房成功并清理 3s/15s 定时器。
|
||||
- 其他:房间状态文案、分享链接生成、离开房间、输入校验(750ms 防抖)。
|
||||
- `useConnectionFeedback.ts`
|
||||
- 状态归一化:`new/connecting`→`negotiating`;`failed/closed`→`disconnected`。
|
||||
- 协商慢提示:8s 定时器(`rtc_slow`),单次协商仅提示一次;若在后台到时则挂起,回到前台且仍协商时补发一次。
|
||||
- 一次性提示:首次 `connected`(`rtc_connected`)仅提示一次;断开前台重连(`rtc_reconnecting`)与恢复(`rtc_restored`)。
|
||||
|
||||
- i18n 文案与类型
|
||||
- 文案定义:`frontend/constants/messages/*.{ts}`(已补齐 zh/en/ja/es/de/fr/ko)。
|
||||
- 类型定义:`frontend/types/messages.ts`(`ClipboardApp` 下包含 `join_*` 与 `rtc_*` 文案键)。
|
||||
- `frontend/components/ClipboardApp/SendTabPanel.tsx` — 发送面板,集成富文本编辑器、文件上传、房间 ID 生成(支持 4 位数字/UUID 两种模式)、分享链接生成。
|
||||
- 体验增强:点击“使用缓存ID”将立即触发加入房间(sender 侧),减少一次手动点击。
|
||||
- `frontend/components/ClipboardApp/RetrieveTabPanel.tsx` — 接收面板,处理房间加入、文件接收、目录选择(File System Access API)、富文本内容显示。
|
||||
|
||||
@@ -258,6 +258,37 @@ Core Services (webrtcService) + Store (fileTransferStore)
|
||||
7. **剪贴板兼容性**:useClipboardActions 支持现代 navigator.clipboard API 和 document.execCommand 降级方案
|
||||
8. **富文本安全处理**:useRichTextToPlainText 服务端渲染安全,客户端 DOM 转换处理块级元素
|
||||
9. **站内导航不中断(同一标签页)**:依赖 `frontend/stores/fileTransferStore.ts`(Zustand 单例)与 `frontend/lib/webrtcService.ts`(服务单例)。App Router 页面切换不打断传输且保留已选择/已接收内容。注意不要在路由切换副作用中调用 `webrtcService.leaveRoom()` 或重置 Store;刷新/新标签不在保证范围内。
|
||||
|
||||
### UI 连接反馈状态机(弱网/VPN 提示)
|
||||
|
||||
- 入房阶段(join)
|
||||
- 立即:`join_inProgress`(“正在加入房间…”)。
|
||||
- 3s 未完成:`join_slow`(“连接较慢,建议检查网络/VPN…”)。
|
||||
- 15s 超时:`join_timeout`(“加入超时…”)。
|
||||
- 等效成功信号:在等待 `joinResponse` 期间,若收到 `ready/recipient-ready/offer`,视为提前入房成功并即时清理 3s/15s 定时器与提示,避免“成功后再出现慢/超时提示”。
|
||||
- 协商阶段(WebRTC)
|
||||
- 进入 `new/connecting`:归一为 “协商中” → `rtc_negotiating`。
|
||||
- 8s 未连上:`rtc_slow`(“网络可能受限,尝试关闭 VPN 或稍后再试”)。仅在页面前台可见时触发;同一次协商尝试仅提示一次(发送端/接收端任一进入协商即启动计时,提示归属以最先进入协商的一侧为准)。
|
||||
- 连接与重连
|
||||
- 首次 `connected`:`rtc_connected`(仅一次)。
|
||||
- 前台断开:`rtc_reconnecting` → 恢复后 `rtc_restored`。
|
||||
- 后台断开不提示;回到前台若仍断开立即提示 `rtc_reconnecting`。
|
||||
- 已断开期间若页面在后台,返回前台时若仍处于协商态且此前触发了慢协商计时,则会补发一次 `rtc_slow` 并标记本次协商已提示,以避免重复。
|
||||
|
||||
实现位置:
|
||||
- `frontend/hooks/useRoomManager.ts`:入房阶段提示与定时器管理(3s 慢网、15s 超时),并在 join 成功/失败时清理定时器;支持“等效成功信号”提前判定成功(`ready/recipient-ready/offer`)。
|
||||
- `frontend/hooks/useConnectionFeedback.ts`:桥接 WebRTC 连接态到 UI 提示。
|
||||
- 状态归一化(mapPhase):`new/connecting`→`negotiating`;`failed/closed`→`disconnected`。
|
||||
- 协商慢提示:8s 定时器、前后台可见性节制、单次协商尝试仅提示一次(含挂起→前台补发)。
|
||||
- 一次性提示:首次 `connected` 只显示一次;断开→恢复显示 `rtc_restored`;仅前台显示 `rtc_reconnecting`。
|
||||
|
||||
文案与 i18n:
|
||||
- 文案键均位于 `frontend/constants/messages/*.{ts}`,类型定义见 `frontend/types/messages.ts`。
|
||||
- 关键键:`join_inProgress`、`join_slow`、`join_timeout`、`rtc_negotiating`、`rtc_slow`、`rtc_connected`、`rtc_reconnecting`、`rtc_restored`(已在 en/ja/es/de/fr/ko 全部补齐)。
|
||||
|
||||
节流与展示:
|
||||
- 所有提示默认 4–6 秒自动消失;通过 `useClipboardAppMessages.putMessageInMs(message, isShareEnd, ms)` 统一展示。
|
||||
- 连接反馈提示在“状态迁移 + ever/wasDisc 标记 + 可见性判断”三重约束下触发,避免提示风暴。
|
||||
10. **切到接收端自动加入(缓存ID)**:当用户切换到接收端、未在房间、URL 无 `roomId`、输入框为空且本地存在缓存 ID 时,自动填充并直接调用加入房间以提升体验。入口:`frontend/components/ClipboardApp.tsx`(监听 `activeTab` 变化,读取 `frontend/lib/roomIdCache.ts`)。
|
||||
11. **发送端“使用缓存ID”即刻加入**:发送端在 `SendTabPanel` 点击“使用缓存ID”后会立即调用加入房间(而非仅填充输入框)。入口:`frontend/components/ClipboardApp/CachedIdActionButton.tsx`(`onUseCached` 回调)+ `frontend/components/ClipboardApp/SendTabPanel.tsx`。
|
||||
12. **深色主题切换**:提供单按钮 Light/Dark 切换,入口:`frontend/components/web/ThemeToggle.tsx`;集成位置:`frontend/components/web/Header.tsx`(桌面与移动);局部样式从硬编码颜色迁移为设计令牌(例如接收面板使用 `bg-card text-card-foreground`)。
|
||||
|
||||
@@ -15,6 +15,7 @@ import FullScreenDropZone from "./ClipboardApp/FullScreenDropZone";
|
||||
import { traverseFileTree } from "@/lib/fileUtils";
|
||||
import { useFileTransferStore } from "@/stores/fileTransferStore";
|
||||
import { getCachedId } from "@/lib/roomIdCache";
|
||||
import { useConnectionFeedback } from "@/hooks/useConnectionFeedback";
|
||||
|
||||
const ClipboardApp = () => {
|
||||
const { shareMessage, retrieveMessage, putMessageInMs } =
|
||||
@@ -29,7 +30,7 @@ const ClipboardApp = () => {
|
||||
retrieveJoinRoomBtnRef,
|
||||
});
|
||||
|
||||
// 从 store 中获取状态
|
||||
// Get state from store
|
||||
const {
|
||||
activeTab,
|
||||
isDragging,
|
||||
@@ -174,6 +175,9 @@ const ClipboardApp = () => {
|
||||
joinRoom,
|
||||
]);
|
||||
|
||||
// Connection feedback observer (Hook)
|
||||
useConnectionFeedback({ messages, putMessageInMs });
|
||||
|
||||
if (isLoadingMessages || !messages) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
|
||||
|
||||
@@ -312,6 +312,19 @@ export const de: Messages = {
|
||||
"Der Raum, dem Sie beitreten möchten, existiert nicht. Nur der Sender kann einen Raum erstellen.",
|
||||
failMsg: "Fehler beim Beitreten zum Raum:",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress:
|
||||
"Beitritt zum Raum… (in langsamen Netzen 5–30 Sekunden)",
|
||||
join_slow:
|
||||
"Wirkt etwas langsam – Netzwerk/VPN prüfen oder später erneut versuchen",
|
||||
join_timeout:
|
||||
"Beitritt zeitüberschritten (mögliche Netzbeschränkung). Bitte erneut versuchen",
|
||||
rtc_slow:
|
||||
"Netzwerk möglicherweise eingeschränkt — VPN deaktivieren oder später erneut versuchen",
|
||||
rtc_negotiating: "Im Raum – direkte P2P‑Verbindung wird aufgebaut…",
|
||||
rtc_connected: "Verbunden",
|
||||
rtc_reconnecting: "Wiederverbinden…",
|
||||
rtc_restored: "Verbindung wiederhergestellt",
|
||||
pickSaveMsg: "Direkt auf Festplatte speichern?",
|
||||
pickSaveUnsupported: "Verzeichnisauswahl nicht unterstützt.",
|
||||
pickSaveSuccess: "Speicherort festgelegt.",
|
||||
|
||||
@@ -306,6 +306,18 @@ export const en: Messages = {
|
||||
"The room you are trying to join does not exist. Only the sender can create a room.",
|
||||
failMsg: "Failed to join room:",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress:
|
||||
"Joining the room… this may take 5–30 seconds on slow networks",
|
||||
join_slow: "Feels slow—check your network/VPN or try again shortly",
|
||||
join_timeout:
|
||||
"Join timed out (network may be restricted). Please try again",
|
||||
rtc_slow:
|
||||
"Network may be restricted — try turning off VPN or try again shortly",
|
||||
rtc_negotiating: "In the room—establishing a direct P2P connection…",
|
||||
rtc_connected: "Connected",
|
||||
rtc_reconnecting: "Reconnecting…",
|
||||
rtc_restored: "Connection restored",
|
||||
pickSaveMsg: "Save Directly to Disk ?",
|
||||
pickSaveUnsupported: "Directory picker not supported.",
|
||||
pickSaveSuccess: "Save location set.",
|
||||
|
||||
@@ -306,6 +306,19 @@ export const es: Messages = {
|
||||
"La sala a la que intentas unirte no existe. Solo el remitente puede crear una sala.",
|
||||
failMsg: "Error al unirse a la sala:",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress:
|
||||
"Uniéndose a la sala… (en redes lentas puede tardar 5–30 s)",
|
||||
join_slow:
|
||||
"Va algo lento—revisa tu red/VPN o inténtalo de nuevo en breve",
|
||||
join_timeout:
|
||||
"La unión ha caducado (posibles restricciones de red). Vuelve a intentarlo",
|
||||
rtc_slow:
|
||||
"La red puede estar restringida — prueba desactivar la VPN o inténtalo de nuevo en breve",
|
||||
rtc_negotiating: "Dentro de la sala—estableciendo conexión P2P directa…",
|
||||
rtc_connected: "Conectado",
|
||||
rtc_reconnecting: "Reconectando…",
|
||||
rtc_restored: "Conexión restaurada",
|
||||
pickSaveMsg: "¿Guardar Directamente en Disco?",
|
||||
pickSaveUnsupported: "Selector de directorio no compatible.",
|
||||
pickSaveSuccess: "Ubicación de guardado establecida.",
|
||||
|
||||
@@ -312,6 +312,19 @@ export const fr: Messages = {
|
||||
"La salle que vous essayez de rejoindre n'existe pas. Seul l'expéditeur peut créer une salle.",
|
||||
failMsg: "Échec de la connexion à la salle :",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress:
|
||||
"Rejoindre la salle… (sur un réseau lent, 5–30 s possibles)",
|
||||
join_slow:
|
||||
"C’est un peu lent — vérifiez votre réseau/VPN ou réessayez bientôt",
|
||||
join_timeout:
|
||||
"Délai dépassé pour rejoindre (réseau possiblement restreint). Réessayez",
|
||||
rtc_slow:
|
||||
"Réseau possiblement restreint — essayez de désactiver le VPN ou réessayez bientôt",
|
||||
rtc_negotiating: "Dans la salle — établissement d’un lien P2P direct…",
|
||||
rtc_connected: "Connecté",
|
||||
rtc_reconnecting: "Reconnexion…",
|
||||
rtc_restored: "Connexion rétablie",
|
||||
pickSaveMsg: "Enregistrer directement sur le disque ?",
|
||||
pickSaveUnsupported: "Sélecteur de répertoire non pris en charge.",
|
||||
pickSaveSuccess: "Emplacement de sauvegarde défini.",
|
||||
|
||||
@@ -302,6 +302,19 @@ export const ja: Messages = {
|
||||
"参加しようとしているルームは存在しません。送信者のみがルームを作成できます。",
|
||||
failMsg: "ルームへの参加に失敗しました:",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress:
|
||||
"ルームに参加中…(回線が遅い環境では 5〜30 秒かかることがあります)",
|
||||
join_slow:
|
||||
"少し時間がかかっています—ネットワーク/VPN をご確認のうえ、しばらくしてからお試しください",
|
||||
join_timeout:
|
||||
"参加がタイムアウトしました(ネットワーク制限の可能性)。再試行してください",
|
||||
rtc_slow:
|
||||
"ネットワークが制限されている可能性があります — VPN をオフにするか、しばらくしてから再試行してください",
|
||||
rtc_negotiating: "入室済み—P2P 接続を確立しています…",
|
||||
rtc_connected: "接続しました",
|
||||
rtc_reconnecting: "再接続中…",
|
||||
rtc_restored: "接続が回復しました",
|
||||
pickSaveMsg: "ディスクに直接保存しますか?",
|
||||
pickSaveUnsupported: "ディレクトリピッカーはサポートされていません。",
|
||||
pickSaveSuccess: "保存場所が設定されました。",
|
||||
|
||||
@@ -300,6 +300,19 @@ export const ko: Messages = {
|
||||
"참여하려는 방이 존재하지 않습니다. 보내는 사람만 방을 만들 수 있습니다.",
|
||||
failMsg: "방 참여 실패:",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress:
|
||||
"방에 참여 중… (느린 네트워크에서는 5–30초가 걸릴 수 있어요)",
|
||||
join_slow:
|
||||
"조금 느려 보여요 — 네트워크/VPN을 확인하거나 잠시 후 다시 시도해 주세요",
|
||||
join_timeout:
|
||||
"참여 시간이 초과되었습니다(네트워크가 제한될 수 있음). 다시 시도해 주세요",
|
||||
rtc_slow:
|
||||
"네트워크가 제한되어 있을 수 있어요 — VPN을 끄거나 잠시 후 다시 시도해 주세요",
|
||||
rtc_negotiating: "입장 완료 — P2P 직접 연결을 설정하는 중…",
|
||||
rtc_connected: "연결되었습니다",
|
||||
rtc_reconnecting: "재연결 중…",
|
||||
rtc_restored: "연결이 복구되었습니다",
|
||||
pickSaveMsg: "직접 디스크에 저장하시겠습니까?",
|
||||
pickSaveUnsupported: "디렉토리 선택기가 지원되지 않습니다.",
|
||||
pickSaveSuccess: "저장 위치가 설정되었습니다.",
|
||||
|
||||
@@ -285,6 +285,15 @@ export const zh: Messages = {
|
||||
notExist: "您尝试加入的房间不存在。只有发送方可以创建房间。",
|
||||
failMsg: "加入房间失败:",
|
||||
},
|
||||
// Connection feedback (weak/VPN network scenarios)
|
||||
join_inProgress: "正在加入房间…(慢网可需 5–30 秒)",
|
||||
join_slow: "连接较慢,建议检查网络/VPN 或稍后重试",
|
||||
join_timeout: "加入超时(网络可能受限),请重试",
|
||||
rtc_slow: "网络可能受限,尝试关闭 VPN 或稍后再试",
|
||||
rtc_negotiating: "已入房,正在建立 P2P 连接…",
|
||||
rtc_connected: "已连接",
|
||||
rtc_reconnecting: "重连中…",
|
||||
rtc_restored: "已恢复连接",
|
||||
pickSaveMsg: "直接保存到磁盘?",
|
||||
pickSaveUnsupported: "不支持目录选择器。",
|
||||
pickSaveSuccess: "保存位置已设置。",
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
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";
|
||||
}
|
||||
|
||||
interface UseConnectionFeedbackProps {
|
||||
messages: Messages | null;
|
||||
putMessageInMs: (
|
||||
message: string,
|
||||
isShareEnd?: boolean,
|
||||
displayTimeMs?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useConnectionFeedback({
|
||||
messages,
|
||||
putMessageInMs,
|
||||
}: UseConnectionFeedbackProps) {
|
||||
const { shareConnectionState, retrieveConnectionState } =
|
||||
useFileTransferStore();
|
||||
|
||||
// Track previous phases and connection history via refs
|
||||
const prevShareRef = useRef<Phase>("idle");
|
||||
const prevRecvRef = useRef<Phase>("idle");
|
||||
const everShareRef = useRef<boolean>(false);
|
||||
const everRecvRef = useRef<boolean>(false);
|
||||
const wasDiscShareRef = useRef<boolean>(false);
|
||||
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);
|
||||
|
||||
// Bridge RTC connection state changes to UI messages
|
||||
useEffect(() => {
|
||||
if (!messages) return;
|
||||
|
||||
const nowShare: Phase = mapPhase(shareConnectionState as any);
|
||||
const nowRecv: Phase = mapPhase(retrieveConnectionState as any);
|
||||
|
||||
const prevShare = prevShareRef.current;
|
||||
const prevRecv = prevRecvRef.current;
|
||||
|
||||
// Update refs for visibility handler to read latest
|
||||
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 (nowShare === "connected") {
|
||||
if (!everShareRef.current) {
|
||||
const msg = messages.text.ClipboardApp.rtc_connected;
|
||||
if (msg) putMessageInMs(msg, true, 4000);
|
||||
}
|
||||
if (wasDiscShareRef.current) {
|
||||
const msg = messages.text.ClipboardApp.rtc_restored;
|
||||
if (msg) putMessageInMs(msg, true, 4000);
|
||||
}
|
||||
everShareRef.current = true;
|
||||
wasDiscShareRef.current = false;
|
||||
clearSlowTimer("share");
|
||||
}
|
||||
if (nowShare === "disconnected") {
|
||||
const isForeground = document.visibilityState === "visible";
|
||||
if ((everShareRef.current || wasDiscShareRef.current) && isForeground) {
|
||||
const msg = messages.text.ClipboardApp.rtc_reconnecting;
|
||||
if (msg) putMessageInMs(msg, true, 4000);
|
||||
wasDiscShareRef.current = true;
|
||||
}
|
||||
clearSlowTimer("share");
|
||||
}
|
||||
|
||||
// Receiver side mapping
|
||||
if (nowRecv === "negotiating" && prevRecv !== "negotiating") {
|
||||
const msg = messages.text.ClipboardApp.rtc_negotiating;
|
||||
if (msg) putMessageInMs(msg, false, 4000);
|
||||
startSlowTimer("recv");
|
||||
}
|
||||
if (nowRecv === "connected") {
|
||||
if (!everRecvRef.current) {
|
||||
const msg = messages.text.ClipboardApp.rtc_connected;
|
||||
if (msg) putMessageInMs(msg, false, 4000);
|
||||
}
|
||||
if (wasDiscRecvRef.current) {
|
||||
const msg = messages.text.ClipboardApp.rtc_restored;
|
||||
if (msg) putMessageInMs(msg, false, 4000);
|
||||
}
|
||||
everRecvRef.current = true;
|
||||
wasDiscRecvRef.current = false;
|
||||
clearSlowTimer("recv");
|
||||
}
|
||||
if (nowRecv === "disconnected") {
|
||||
const isForeground = document.visibilityState === "visible";
|
||||
if ((everRecvRef.current || wasDiscRecvRef.current) && isForeground) {
|
||||
const msg = messages.text.ClipboardApp.rtc_reconnecting;
|
||||
if (msg) putMessageInMs(msg, false, 4000);
|
||||
wasDiscRecvRef.current = true;
|
||||
}
|
||||
clearSlowTimer("recv");
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Save previous for next comparison
|
||||
prevShareRef.current = nowShare;
|
||||
prevRecvRef.current = nowRecv;
|
||||
}, [messages, shareConnectionState, retrieveConnectionState, putMessageInMs]);
|
||||
|
||||
// Visibility change: when returning to foreground, if still disconnected, hint "reconnecting"
|
||||
useEffect(() => {
|
||||
if (!messages) return;
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
|
||||
const nowShare = sharePhaseRef.current;
|
||||
const nowRecv = recvPhaseRef.current;
|
||||
|
||||
if (
|
||||
(everShareRef.current || wasDiscShareRef.current) &&
|
||||
nowShare === "disconnected"
|
||||
) {
|
||||
const msg = messages.text.ClipboardApp.rtc_reconnecting;
|
||||
if (msg) putMessageInMs(msg, true, 4000);
|
||||
wasDiscShareRef.current = true;
|
||||
}
|
||||
if (
|
||||
(everRecvRef.current || wasDiscRecvRef.current) &&
|
||||
nowRecv === "disconnected"
|
||||
) {
|
||||
const msg = messages.text.ClipboardApp.rtc_reconnecting;
|
||||
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);
|
||||
return () => {
|
||||
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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -51,7 +51,26 @@ export function useRoomManager({
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Immediate feedback on click
|
||||
const joinInProgressMsg = messages.text.ClipboardApp.join_inProgress;
|
||||
putMessageInMs(joinInProgressMsg, isSenderSide, 6000);
|
||||
|
||||
// 3s slow-network hint
|
||||
slowJoinTimer = setTimeout(() => {
|
||||
const joinSlowMsg = messages.text.ClipboardApp.join_slow;
|
||||
putMessageInMs(joinSlowMsg, isSenderSide, 6000);
|
||||
}, 3000);
|
||||
|
||||
// If it's the sender side and the room ID is not the initial ID, need to create the room first
|
||||
if (
|
||||
isSenderSide &&
|
||||
@@ -65,6 +84,7 @@ export function useRoomManager({
|
||||
messages.text.ClipboardApp.joinRoom.DuplicateMsg,
|
||||
isSenderSide
|
||||
);
|
||||
clearJoinTimers();
|
||||
return;
|
||||
}
|
||||
setShareRoomId(roomId);
|
||||
@@ -74,6 +94,7 @@ export function useRoomManager({
|
||||
` (Create room error)`,
|
||||
isSenderSide
|
||||
);
|
||||
clearJoinTimers();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -89,7 +110,9 @@ export function useRoomManager({
|
||||
// If sender uses a long ID (e.g., cached UUID), proactively send
|
||||
// "initiator-online" after join to trigger receivers' re-handshake.
|
||||
const forceInitiatorOnline =
|
||||
isSenderSide && typeof actualRoomId === "string" && actualRoomId.length >= 8;
|
||||
isSenderSide &&
|
||||
typeof actualRoomId === "string" &&
|
||||
actualRoomId.length >= 8;
|
||||
|
||||
// Directly call the service method without dependency injection
|
||||
await webrtcService.joinRoom(
|
||||
@@ -98,6 +121,7 @@ export function useRoomManager({
|
||||
forceInitiatorOnline
|
||||
);
|
||||
|
||||
clearJoinTimers();
|
||||
putMessageInMs(
|
||||
messages.text.ClipboardApp.joinRoom.successMsg,
|
||||
isSenderSide,
|
||||
@@ -119,8 +143,12 @@ export function useRoomManager({
|
||||
errorMsg =
|
||||
error.message === "Room does not exist"
|
||||
? messages.text.ClipboardApp.joinRoom.notExist
|
||||
: error.message === "Join room timeout"
|
||||
? messages.text.ClipboardApp.join_timeout
|
||||
: `${messages.text.ClipboardApp.joinRoom.failMsg} ${error.message}`;
|
||||
}
|
||||
// Clear joining timers on failure
|
||||
clearJoinTimers();
|
||||
putMessageInMs(errorMsg, isSenderSide);
|
||||
}
|
||||
},
|
||||
@@ -162,7 +190,9 @@ export function useRoomManager({
|
||||
|
||||
// Check if files are transferring and show confirmation
|
||||
if (isAnyFileTransferring) {
|
||||
const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring);
|
||||
const confirmed = window.confirm(
|
||||
messages.text.ClipboardApp.confirmLeaveWhileTransferring
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
@@ -175,7 +205,7 @@ export function useRoomManager({
|
||||
);
|
||||
}
|
||||
|
||||
const message = isAnyFileTransferring
|
||||
const message = isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.leaveWhileTransferringSuccess
|
||||
: messages.text.ClipboardApp.roomStatus.leftRoomMsg;
|
||||
putMessageInMs(message, false);
|
||||
@@ -216,7 +246,9 @@ export function useRoomManager({
|
||||
|
||||
// Check if files are transferring and show confirmation
|
||||
if (isAnyFileTransferring) {
|
||||
const confirmed = window.confirm(messages.text.ClipboardApp.confirmLeaveWhileTransferring);
|
||||
const confirmed = window.confirm(
|
||||
messages.text.ClipboardApp.confirmLeaveWhileTransferring
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
@@ -229,7 +261,7 @@ export function useRoomManager({
|
||||
);
|
||||
}
|
||||
|
||||
const message = isAnyFileTransferring
|
||||
const message = isAnyFileTransferring
|
||||
? messages.text.ClipboardApp.leaveWhileTransferringSuccess
|
||||
: messages.text.ClipboardApp.roomStatus.leftRoomMsg;
|
||||
putMessageInMs(message, true);
|
||||
@@ -243,7 +275,7 @@ export function useRoomManager({
|
||||
}, [messages, putMessageInMs, resetSenderAppState, isAnyFileTransferring]);
|
||||
|
||||
// Room ID input processing
|
||||
const processRoomIdInput = useCallback(
|
||||
const processRoomIdInput = useCallback(
|
||||
debounce(async (input: string) => {
|
||||
if (!input.trim() || !messages) return;
|
||||
|
||||
|
||||
@@ -296,6 +296,16 @@ export type ClipboardApp = {
|
||||
leaveWhileTransferringSuccess: string;
|
||||
// New: cache messages
|
||||
saveId_success: string;
|
||||
// UI connection feedback
|
||||
join_inProgress: string;
|
||||
join_slow: string;
|
||||
join_timeout: string;
|
||||
// Slow P2P negotiation hint
|
||||
rtc_slow: string;
|
||||
rtc_negotiating: string;
|
||||
rtc_connected: string;
|
||||
rtc_reconnecting: string;
|
||||
rtc_restored: string;
|
||||
};
|
||||
|
||||
export type Home = {
|
||||
|
||||
Reference in New Issue
Block a user