feat: Add idle detection and disconnect collaboration socket (#2629)

This commit is contained in:
Tom Moor 2021-10-06 20:37:21 -04:00 committed by GitHub
parent b39d4aade7
commit be905a6993
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 116 additions and 15 deletions

54
app/hooks/useIdle.js Normal file
View 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;
}

View 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;
}

View File

@ -1,5 +1,5 @@
// @flow
import { HocuspocusProvider } from "@hocuspocus/provider";
import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router";
@ -9,9 +9,10 @@ import Editor, { type Props as EditorProps } from "components/Editor";
import env from "env";
import useCurrentToken from "hooks/useCurrentToken";
import useCurrentUser from "hooks/useCurrentUser";
import useIdle from "hooks/useIdle";
import usePageVisibility from "hooks/usePageVisibility";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import useUnmount from "hooks/useUnmount";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { homeUrl } from "utils/routeHelpers";
@ -27,12 +28,13 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
const currentUser = useCurrentUser();
const { presence, ui } = useStores();
const token = useCurrentToken();
const [localProvider, setLocalProvider] = React.useState();
const [remoteProvider, setRemoteProvider] = React.useState();
const [isLocalSynced, setLocalSynced] = React.useState(false);
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [ydoc] = React.useState(() => new Y.Doc());
const { showToast } = useToasts();
const isIdle = useIdle();
const isVisible = usePageVisibility();
// Provider initialization must be within useLayoutEffect rather than useState
// 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));
setRemoteProvider(provider);
setLocalProvider(localProvider);
return () => {
provider?.destroy();
localProvider?.destroy();
setRemoteProvider(null);
ui.setMultiplayerStatus(undefined);
};
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
const user = React.useMemo(() => {
@ -116,11 +126,26 @@ function MultiplayerEditor({ ...props }: Props, ref: any) {
];
}, [remoteProvider, user, ydoc]);
useUnmount(() => {
remoteProvider?.destroy();
localProvider?.destroy();
ui.setMultiplayerStatus(undefined);
});
// Disconnect the realtime connection while idle. `isIdle` also checks for
// page visibility and will immediately disconnect when a tab is hidden.
React.useEffect(() => {
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) {
return null;

View File

@ -48,7 +48,7 @@
"@babel/preset-react": "^7.10.4",
"@bull-board/api": "^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",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",

View File

@ -510,7 +510,7 @@
"Upload": "Upload",
"Subdomain": "Subdomain",
"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",
"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.",

View File

@ -1155,10 +1155,10 @@
dependencies:
"@hapi/hoek" "^8.3.0"
"@hocuspocus/provider@^1.0.0-alpha.16":
version "1.0.0-alpha.16"
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-1.0.0-alpha.16.tgz#f14e10a6961a377564aabb8a6d443b8b512fa49d"
integrity sha512-kp3oteq64ruUAVXcEW4HlLlg2yZtZSxLN9/JMGaCERFRm+D+cvzJNadNh35mbO5a2Me612wj7lSV/OEw0ugQAw==
"@hocuspocus/provider@^1.0.0-alpha.18":
version "1.0.0-alpha.18"
resolved "https://registry.yarnpkg.com/@hocuspocus/provider/-/provider-1.0.0-alpha.18.tgz#670b052a2bd8b634e05ec282f4453bfd92779906"
integrity sha512-KLszadquMZHKTdR9CQhAn97Gcn6TED6DKPpJ+9Ni68gCOfYUSZrI4pwIeOOgskYXAE/pI3Gfb0qW/OBMhKdT1w==
dependencies:
"@lifeomic/attempt" "^3.0.0"
lib0 "^0.2.42"