mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 08:32:38 +10:00
feat: disconnect collab websocket on idle tabs (#848)
* disconnect real-time collab if user is idle * log yjs document disconnect and unload in dev mode * no longer set editor to read-only mode on collab websocket disconnection * treat delayed collab websocket "connecting" state as disconnected * increase maxDebounce to 45 seconds * add reset handle to useIdle hook
This commit is contained in:
@ -41,7 +41,9 @@ import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import { isCloud } from "@/lib/config.ts";
|
||||
import { useDocumentVisibility } from "@mantine/hooks";
|
||||
import { useIdle } from "@/hooks/use-idle.ts";
|
||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@ -69,6 +71,8 @@ export default function PageEditor({
|
||||
const menuContainerRef = useRef(null);
|
||||
const documentName = `page.${pageId}`;
|
||||
const { data } = useCollabToken();
|
||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||
const documentState = useDocumentVisibility();
|
||||
|
||||
const localProvider = useMemo(() => {
|
||||
const provider = new IndexeddbPersistence(documentName, ydoc);
|
||||
@ -87,6 +91,7 @@ export default function PageEditor({
|
||||
document: ydoc,
|
||||
token: data?.token,
|
||||
connect: false,
|
||||
preserveConnection: false,
|
||||
onStatus: (status) => {
|
||||
if (status.status === "connected") {
|
||||
setYjsConnectionStatus(status.status);
|
||||
@ -126,6 +131,7 @@ export default function PageEditor({
|
||||
extensions,
|
||||
editable,
|
||||
immediatelyRender: true,
|
||||
shouldRerenderOnTransaction: false,
|
||||
editorProps: {
|
||||
scrollThreshold: 80,
|
||||
scrollMargin: 80,
|
||||
@ -158,7 +164,7 @@ export default function PageEditor({
|
||||
}
|
||||
},
|
||||
},
|
||||
[pageId, editable, remoteProvider],
|
||||
[pageId, editable, remoteProvider?.status],
|
||||
);
|
||||
|
||||
const handleActiveCommentEvent = (event) => {
|
||||
@ -188,16 +194,31 @@ export default function PageEditor({
|
||||
}, [pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloud()) return;
|
||||
if (editable) {
|
||||
if (yjsConnectionStatus === WebSocketStatus.Connected) {
|
||||
editor.setEditable(true);
|
||||
} else {
|
||||
// disable edits if connection fails
|
||||
editor.setEditable(false);
|
||||
}
|
||||
if (remoteProvider?.status === WebSocketStatus.Connecting) {
|
||||
const timeout = setTimeout(() => {
|
||||
setYjsConnectionStatus(WebSocketStatus.Disconnected);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [yjsConnectionStatus]);
|
||||
}, [remoteProvider?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isIdle &&
|
||||
documentState === "hidden" &&
|
||||
remoteProvider?.status === WebSocketStatus.Connected
|
||||
) {
|
||||
remoteProvider.disconnect();
|
||||
}
|
||||
|
||||
if (
|
||||
documentState === "visible" &&
|
||||
remoteProvider?.status === WebSocketStatus.Disconnected
|
||||
) {
|
||||
remoteProvider.connect();
|
||||
resetIdle();
|
||||
}
|
||||
}, [isIdle, documentState, remoteProvider?.status]);
|
||||
|
||||
const isSynced = isLocalSynced && isRemoteSynced;
|
||||
|
||||
|
||||
58
apps/client/src/hooks/use-idle.ts
Normal file
58
apps/client/src/hooks/use-idle.ts
Normal file
@ -0,0 +1,58 @@
|
||||
// Mantine Idle hook to support reset handle - MIT
|
||||
//src: https://github.com/mantinedev/mantine/blob/06018d0beff22caa7b36d796e56ad597cc5c23f7/packages/%40mantine/hooks/src/use-idle/use-idle.ts
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const DEFAULT_EVENTS: (keyof DocumentEventMap)[] = [
|
||||
"keypress",
|
||||
"mousemove",
|
||||
"touchmove",
|
||||
"click",
|
||||
"scroll",
|
||||
];
|
||||
const DEFAULT_OPTIONS = {
|
||||
events: DEFAULT_EVENTS,
|
||||
initialState: true,
|
||||
};
|
||||
|
||||
export function useIdle(
|
||||
timeout: number,
|
||||
options?: Partial<{
|
||||
events: (keyof DocumentEventMap)[];
|
||||
initialState: boolean;
|
||||
}>,
|
||||
) {
|
||||
const { events, initialState } = { ...DEFAULT_OPTIONS, ...options };
|
||||
const [idle, setIdle] = useState<boolean>(initialState);
|
||||
const timer = useRef<number>(-1);
|
||||
|
||||
const reset = () => {
|
||||
setIdle(false);
|
||||
if (timer.current) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
timer.current = window.setTimeout(() => {
|
||||
setIdle(true);
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEvents = () => {
|
||||
reset();
|
||||
};
|
||||
|
||||
events.forEach((event) => document.addEventListener(event, handleEvents));
|
||||
|
||||
// Start the timer immediately instead of waiting for the first event to happen
|
||||
timer.current = window.setTimeout(() => {
|
||||
setIdle(true);
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
events.forEach((event) =>
|
||||
document.removeEventListener(event, handleEvents),
|
||||
);
|
||||
};
|
||||
}, [timeout, events]);
|
||||
|
||||
return { isIdle: idle, resetIdle: reset };
|
||||
}
|
||||
@ -1,2 +1,4 @@
|
||||
export const INTERNAL_LINK_REGEX =
|
||||
/^(https?:\/\/)?([^\/]+)?(\/s\/([^\/]+)\/)?p\/([a-zA-Z0-9-]+)\/?$/;
|
||||
|
||||
export const FIVE_MINUTES = 5 * 60 * 1000;
|
||||
Reference in New Issue
Block a user