From be905a699359c18eb373505d04575d5219d8cb52 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 6 Oct 2021 20:37:21 -0400 Subject: [PATCH] feat: Add idle detection and disconnect collaboration socket (#2629) --- app/hooks/useIdle.js | 54 +++++++++++++++++++ app/hooks/usePageVisibility.js | 22 ++++++++ .../Document/components/MultiplayerEditor.js | 43 +++++++++++---- package.json | 2 +- shared/i18n/locales/en_US/translation.json | 2 +- yarn.lock | 8 +-- 6 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 app/hooks/useIdle.js create mode 100644 app/hooks/usePageVisibility.js diff --git a/app/hooks/useIdle.js b/app/hooks/useIdle.js new file mode 100644 index 000000000..cd42bdaff --- /dev/null +++ b/app/hooks/useIdle.js @@ -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; +} diff --git a/app/hooks/usePageVisibility.js b/app/hooks/usePageVisibility.js new file mode 100644 index 000000000..3bf5fb88c --- /dev/null +++ b/app/hooks/usePageVisibility.js @@ -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; +} diff --git a/app/scenes/Document/components/MultiplayerEditor.js b/app/scenes/Document/components/MultiplayerEditor.js index 5b1dc1011..8c2312a1f 100644 --- a/app/scenes/Document/components/MultiplayerEditor.js +++ b/app/scenes/Document/components/MultiplayerEditor.js @@ -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; diff --git a/package.json b/package.json index 444875d66..1761fa389 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index d20cda745..93061ff7b 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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.", diff --git a/yarn.lock b/yarn.lock index 915745b6d..6b6d0fba5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"