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:
david_bai
2025-12-05 19:10:00 +08:00
parent 621d65bdfd
commit 761921684c
13 changed files with 452 additions and 7 deletions
+15
View File
@@ -24,6 +24,21 @@
- `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 慢连接提示、断开/重连/恢复提示(前台可见时提示)。
- `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)、富文本内容显示。
+31
View File
@@ -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`)。
+5 -1
View File
@@ -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">
+13
View File
@@ -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 530 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 P2PVerbindung 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.",
+12
View File
@@ -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 530 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.",
+13
View File
@@ -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 530 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.",
+13
View File
@@ -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, 530 s possibles)",
join_slow:
"Cest 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 dun 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.",
+13
View File
@@ -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: "保存場所が設定されました。",
+13
View File
@@ -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: "저장 위치가 설정되었습니다.",
+9
View File
@@ -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: "保存位置已设置。",
+267
View File
@@ -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);
};
}, []);
}
+38 -6
View File
@@ -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;
+10
View File
@@ -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 = {