feat: Add idle detection and disconnect collaboration socket (#2629)
This commit is contained in:
54
app/hooks/useIdle.js
Normal file
54
app/hooks/useIdle.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
const activityEvents = [
|
||||||
|
"click",
|
||||||
|
"mousemove",
|
||||||
|
"keydown",
|
||||||
|
"DOMMouseScroll",
|
||||||
|
"mousewheel",
|
||||||
|
"mousedown",
|
||||||
|
"touchstart",
|
||||||
|
"touchmove",
|
||||||
|
"focus",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect user idle state.
|
||||||
|
*
|
||||||
|
* @param {number} timeToIdle
|
||||||
|
* @returns boolean if the user is idle
|
||||||
|
*/
|
||||||
|
export default function useIdle(timeToIdle: number = 3 * 60 * 1000) {
|
||||||
|
const [isIdle, setIsIdle] = React.useState(false);
|
||||||
|
const timeout = React.useRef();
|
||||||
|
|
||||||
|
const onActivity = React.useCallback(() => {
|
||||||
|
if (timeout.current) {
|
||||||
|
clearTimeout(timeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout.current = setTimeout(() => {
|
||||||
|
setIsIdle(true);
|
||||||
|
}, timeToIdle);
|
||||||
|
}, [timeToIdle]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleUserActivityEvent = () => {
|
||||||
|
setIsIdle(false);
|
||||||
|
onActivity();
|
||||||
|
};
|
||||||
|
|
||||||
|
activityEvents.forEach((eventName) =>
|
||||||
|
window.addEventListener(eventName, handleUserActivityEvent)
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
activityEvents.forEach((eventName) =>
|
||||||
|
window.removeEventListener(eventName, handleUserActivityEvent)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [onActivity]);
|
||||||
|
|
||||||
|
return isIdle;
|
||||||
|
}
|
||||||
22
app/hooks/usePageVisibility.js
Normal file
22
app/hooks/usePageVisibility.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to return page visibility state.
|
||||||
|
*
|
||||||
|
* @returns boolean if the page is visible
|
||||||
|
*/
|
||||||
|
export default function usePageVisibility(): boolean {
|
||||||
|
const [visible, setVisible] = React.useState(true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => setVisible(!document.hidden);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router";
|
import { useHistory } from "react-router";
|
||||||
@@ -9,9 +9,10 @@ import Editor, { type Props as EditorProps } from "components/Editor";
|
|||||||
import env from "env";
|
import env from "env";
|
||||||
import useCurrentToken from "hooks/useCurrentToken";
|
import useCurrentToken from "hooks/useCurrentToken";
|
||||||
import useCurrentUser from "hooks/useCurrentUser";
|
import useCurrentUser from "hooks/useCurrentUser";
|
||||||
|
import useIdle from "hooks/useIdle";
|
||||||
|
import usePageVisibility from "hooks/usePageVisibility";
|
||||||
import useStores from "hooks/useStores";
|
import useStores from "hooks/useStores";
|
||||||
import useToasts from "hooks/useToasts";
|
import useToasts from "hooks/useToasts";
|
||||||
import useUnmount from "hooks/useUnmount";
|
|
||||||
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
|
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
|
||||||
import { homeUrl } from "utils/routeHelpers";
|
import { homeUrl } from "utils/routeHelpers";
|
||||||
|
|
||||||
@@ -27,12 +28,13 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
|
|||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const { presence, ui } = useStores();
|
const { presence, ui } = useStores();
|
||||||
const token = useCurrentToken();
|
const token = useCurrentToken();
|
||||||
const [localProvider, setLocalProvider] = React.useState();
|
|
||||||
const [remoteProvider, setRemoteProvider] = React.useState();
|
const [remoteProvider, setRemoteProvider] = React.useState();
|
||||||
const [isLocalSynced, setLocalSynced] = React.useState(false);
|
const [isLocalSynced, setLocalSynced] = React.useState(false);
|
||||||
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
|
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
|
||||||
const [ydoc] = React.useState(() => new Y.Doc());
|
const [ydoc] = React.useState(() => new Y.Doc());
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
|
const isIdle = useIdle();
|
||||||
|
const isVisible = usePageVisibility();
|
||||||
|
|
||||||
// Provider initialization must be within useLayoutEffect rather than useState
|
// Provider initialization must be within useLayoutEffect rather than useState
|
||||||
// or useMemo as both of these are ran twice in React StrictMode resulting in
|
// or useMemo as both of these are ran twice in React StrictMode resulting in
|
||||||
@@ -91,7 +93,15 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
|
|||||||
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
|
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
|
||||||
|
|
||||||
setRemoteProvider(provider);
|
setRemoteProvider(provider);
|
||||||
setLocalProvider(localProvider);
|
|
||||||
|
return () => {
|
||||||
|
provider?.destroy();
|
||||||
|
localProvider?.destroy();
|
||||||
|
|
||||||
|
setRemoteProvider(null);
|
||||||
|
|
||||||
|
ui.setMultiplayerStatus(undefined);
|
||||||
|
};
|
||||||
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
|
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
|
||||||
|
|
||||||
const user = React.useMemo(() => {
|
const user = React.useMemo(() => {
|
||||||
@@ -116,11 +126,26 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
|
|||||||
];
|
];
|
||||||
}, [remoteProvider, user, ydoc]);
|
}, [remoteProvider, user, ydoc]);
|
||||||
|
|
||||||
useUnmount(() => {
|
// Disconnect the realtime connection while idle. `isIdle` also checks for
|
||||||
remoteProvider?.destroy();
|
// page visibility and will immediately disconnect when a tab is hidden.
|
||||||
localProvider?.destroy();
|
React.useEffect(() => {
|
||||||
ui.setMultiplayerStatus(undefined);
|
if (!remoteProvider) {
|
||||||
});
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
!isVisible &&
|
||||||
|
remoteProvider.status === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
remoteProvider.disconnect();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(!isIdle || isVisible) &&
|
||||||
|
remoteProvider.status === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
remoteProvider.connect();
|
||||||
|
}
|
||||||
|
}, [remoteProvider, isIdle, isVisible]);
|
||||||
|
|
||||||
if (!extensions.length) {
|
if (!extensions.length) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"@babel/preset-react": "^7.10.4",
|
"@babel/preset-react": "^7.10.4",
|
||||||
"@bull-board/api": "^3.5.0",
|
"@bull-board/api": "^3.5.0",
|
||||||
"@bull-board/koa": "^3.5.0",
|
"@bull-board/koa": "^3.5.0",
|
||||||
"@hocuspocus/provider": "^1.0.0-alpha.16",
|
"@hocuspocus/provider": "^1.0.0-alpha.18",
|
||||||
"@hocuspocus/server": "^1.0.0-alpha.73",
|
"@hocuspocus/server": "^1.0.0-alpha.73",
|
||||||
"@outlinewiki/koa-passport": "^4.1.4",
|
"@outlinewiki/koa-passport": "^4.1.4",
|
||||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||||
|
|||||||
@@ -510,7 +510,7 @@
|
|||||||
"Upload": "Upload",
|
"Upload": "Upload",
|
||||||
"Subdomain": "Subdomain",
|
"Subdomain": "Subdomain",
|
||||||
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
"Your knowledge base will be accessible at": "Your knowledge base will be accessible at",
|
||||||
"Manage optional and beta features.": "Manage optional and beta features.",
|
"Manage optional and beta features. Changing these settings will affect all team members.": "Manage optional and beta features. Changing these settings will affect all team members.",
|
||||||
"Collaborative editing": "Collaborative editing",
|
"Collaborative editing": "Collaborative editing",
|
||||||
"New group": "New group",
|
"New group": "New group",
|
||||||
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
"Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.",
|
||||||
|
|||||||
@@ -1155,10 +1155,10 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@hapi/hoek" "^8.3.0"
|
"@hapi/hoek" "^8.3.0"
|
||||||
|
|
||||||
"@hocuspocus/provider@^1.0.0-alpha.16":
|
"@hocuspocus/provider@^1.0.0-alpha.18":
|
||||||
version "1.0.0-alpha.16"
|
version "1.0.0-alpha.18"
|
||||||
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-1.0.0-alpha.16.tgz#f14e10a6961a377564aabb8a6d443b8b512fa49d"
|
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-1.0.0-alpha.18.tgz#670b052a2bd8b634e05ec282f4453bfd92779906"
|
||||||
integrity sha512-kp3oteq64ruUAVXcEW4HlLlg2yZtZSxLN9/JMGaCERFRm+D+cvzJNadNh35mbO5a2Me612wj7lSV/OEw0ugQAw==
|
integrity sha512-KLszadquMZHKTdR9CQhAn97Gcn6TED6DKPpJ+9Ni68gCOfYUSZrI4pwIeOOgskYXAE/pI3Gfb0qW/OBMhKdT1w==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@lifeomic/attempt" "^3.0.0"
|
"@lifeomic/attempt" "^3.0.0"
|
||||||
lib0 "^0.2.42"
|
lib0 "^0.2.42"
|
||||||
|
|||||||
Reference in New Issue
Block a user