// @flow import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; 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 useIsMounted from "hooks/useIsMounted"; import usePageVisibility from "hooks/usePageVisibility"; import useStores from "hooks/useStores"; import useToasts from "hooks/useToasts"; import MultiplayerExtension from "multiplayer/MultiplayerExtension"; import { homePath } from "utils/routeHelpers"; type Props = {| ...EditorProps, id: string, |}; function MultiplayerEditor({ ...props }: Props, ref: any) { const documentId = props.id; const history = useHistory(); const { t } = useTranslation(); const currentUser = useCurrentUser(); const { presence, ui } = useStores(); const token = useCurrentToken(); const [showCursorNames, setShowCursorNames] = React.useState(false); 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(); const isMounted = useIsMounted(); // Provider initialization must be within useLayoutEffect rather than useState // or useMemo as both of these are ran twice in React StrictMode resulting in // an orphaned websocket connection. // see: https://github.com/facebook/react/issues/20090#issuecomment-715926549 React.useLayoutEffect(() => { const debug = env.ENVIRONMENT === "development"; const name = `document.${documentId}`; const localProvider = new IndexeddbPersistence(name, ydoc); const provider = new HocuspocusProvider({ url: `${env.COLLABORATION_URL}/collaboration`, debug, name, document: ydoc, token, maxReconnectTimeout: 10000, }); provider.on("authenticationFailed", () => { showToast( t( "Sorry, it looks like you don’t have permission to access the document" ) ); history.replace(homePath()); }); provider.on("awarenessChange", ({ states }) => { states.forEach(({ user, cursor }) => { if (user) { // could know if the user is editing here using `state.cursor` but it // feels distracting in the UI, once multiplayer is on for everyone we // can stop diffentiating presence.touch(documentId, user.id, !!cursor); } }); }); const showCursorNames = () => { setShowCursorNames(true); setTimeout(() => { if (isMounted()) { setShowCursorNames(false); } }, 2000); provider.off("awarenessChange", showCursorNames); }; provider.on("awarenessChange", showCursorNames); localProvider.on("synced", () => // only set local storage to "synced" if it's loaded a non-empty doc setLocalSynced(!!ydoc.get("default")._start) ); provider.on("synced", () => { presence.touch(documentId, currentUser.id, false); setRemoteSynced(true); }); if (debug) { provider.on("status", (ev) => console.log("status", ev.status)); provider.on("message", (ev) => console.log("incoming", ev.message)); provider.on("outgoingMessage", (ev) => console.log("outgoing", ev.message) ); localProvider.on("synced", (ev) => console.log("local synced")); } provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status)); setRemoteProvider(provider); return () => { provider?.destroy(); localProvider?.destroy(); setRemoteProvider(null); ui.setMultiplayerStatus(undefined); }; }, [ history, showToast, t, documentId, ui, presence, token, ydoc, currentUser.id, ]); const user = React.useMemo(() => { return { id: currentUser.id, name: currentUser.name, color: currentUser.color, }; }, [currentUser.id, currentUser.color, currentUser.name]); const extensions = React.useMemo(() => { if (!remoteProvider) { return []; } return [ new MultiplayerExtension({ user, provider: remoteProvider, document: ydoc, }), ]; }, [remoteProvider, user, ydoc]); // 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; } // while the collaborative document is loading, we render a version of the // document from the last text cache in read-only mode if we have it. const showCache = !isLocalSynced && !isRemoteSynced; return ( <> {showCache && ( )} ); } export default React.forwardRef( MultiplayerEditor );