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:
@@ -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 文案与类型
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user