import { HocuspocusProvider, WebSocketStatus } from "@hocuspocus/provider"; import { throttle } from "es-toolkit/compat"; import { useState, useLayoutEffect, useMemo, useEffect, forwardRef, useRef, type ForwardedRef, } from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; import { toast } from "sonner"; import { IndexeddbPersistence } from "y-indexeddb"; import * as Y from "yjs"; import { EditorUpdateError } from "@shared/collaboration/CloseEvents"; import EDITOR_VERSION from "@shared/editor/version"; import { supportsPassiveListener } from "@shared/utils/browser"; import type { Props as EditorProps } from "~/components/Editor"; import Editor from "~/components/Editor"; import type { Editor as SharedEditor } from "~/editor"; import MultiplayerExtension from "~/editor/extensions/Multiplayer"; import env from "~/env"; 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 type { AwarenessChangeEvent } from "~/types"; import Logger from "~/utils/Logger"; import { homePath } from "~/utils/routeHelpers"; import { sleep } from "@shared/utils/timers"; type Props = EditorProps & { id: string; onSynced?: () => Promise; }; export type ConnectionStatus = | "connecting" | "connected" | "disconnected" | void; type ConnectionStatusEvent = { status: ConnectionStatus }; type MessageEvent = { message: string; event: Event & { code?: number; }; }; function MultiplayerEditor( { onSynced, ...props }: Props, ref: ForwardedRef ) { const documentId = props.id; const history = useHistory(); const { t } = useTranslation(); const currentUser = useCurrentUser(); const retryCount = useRef(0); const { presence, auth, ui } = useStores(); const [editorVersionBehind, setEditorVersionBehind] = useState(false); const [showCursorNames, setShowCursorNames] = useState(false); const [remoteProvider, setRemoteProvider] = useState(); const [hasLocalPersistence, setHasLocalPersistence] = useState(true); const [isLocalSynced, setLocalSynced] = useState(false); const [isRemoteSynced, setRemoteSynced] = useState(false); const [ydoc] = useState(() => new Y.Doc()); const token = auth.collaborationToken; 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 useLayoutEffect(() => { const debug = env.ENVIRONMENT === "development"; const name = `document.${documentId}`; const localProvider = typeof indexedDB !== "undefined" ? new IndexeddbPersistence(name, ydoc) : undefined; if (!localProvider) { setHasLocalPersistence(false); } const provider = new HocuspocusProvider({ parameters: { editorVersion: EDITOR_VERSION, }, url: `${env.COLLABORATION_URL}/collaboration`, name, document: ydoc, token, }); const syncScrollPosition = throttle(() => { provider.setAwarenessField( "scrollY", window.scrollY / window.innerHeight ); }, 250); const finishObserving = () => { if (ui.observingUserId) { ui.setObservingUser(undefined); } }; window.addEventListener("click", finishObserving); window.addEventListener("wheel", finishObserving); window.addEventListener( "scroll", syncScrollPosition, supportsPassiveListener ? { passive: true } : false ); provider.on("authenticationFailed", () => { provider.shouldConnect = false; retryCount.current++; void sleep(retryCount.current * 1000 - 1000).then(() => auth .fetchAuth() .then(() => { provider.setConfiguration({ token: auth.collaborationToken }); void provider.connect(); provider.shouldConnect = true; }) .catch(() => { history.replace(homePath()); }) ); }); provider.on("awarenessChange", (event: AwarenessChangeEvent) => { presence.updateFromAwarenessChangeEvent( documentId, provider.awareness.clientID, event ); event.states.forEach(({ user, scrollY }) => { if (user) { if (scrollY !== undefined && user.id === ui.observingUserId) { window.scrollTo({ top: scrollY * window.innerHeight, behavior: "smooth", }); } } }); }); 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); retryCount.current = 0; }); provider.on("close", (ev: MessageEvent) => { if ("code" in ev.event) { // Note other close code are handled internally by the library if (ev.event.code === EditorUpdateError.code) { provider.shouldConnect = false; } ui.setMultiplayerStatus("disconnected", ev.event.code); if (ev.event.code === EditorUpdateError.code) { setEditorVersionBehind(true); } } }); if (debug) { provider.on("close", (ev: MessageEvent) => Logger.debug("collaboration", "close", ev) ); provider.on("message", (ev: MessageEvent) => Logger.debug("collaboration", "incoming", { message: ev.message, }) ); provider.on("outgoingMessage", (ev: MessageEvent) => Logger.debug("collaboration", "outgoing", { message: ev.message, }) ); localProvider?.on("synced", () => Logger.debug("collaboration", "local synced") ); } provider.on("status", (ev: ConnectionStatusEvent) => { if (ui.multiplayerStatus !== ev.status) { ui.setMultiplayerStatus(ev.status, undefined); } }); setRemoteProvider(provider); return () => { window.removeEventListener("click", finishObserving); window.removeEventListener("wheel", finishObserving); window.removeEventListener("scroll", syncScrollPosition); provider?.destroy(); void localProvider?.destroy(); setRemoteProvider(undefined); ui.setMultiplayerStatus(undefined, undefined); }; }, [ history, t, documentId, ui, presence, ydoc, currentUser.id, isMounted, auth, ]); const user = useMemo( () => ({ id: currentUser.id, name: currentUser.name, color: currentUser.color, }), [currentUser.id, currentUser.color, currentUser.name] ); const extensions = useMemo(() => { if (!remoteProvider) { return props.extensions; } return [ ...(props.extensions || []), new MultiplayerExtension({ user, provider: remoteProvider, document: ydoc, }), ]; }, [remoteProvider, user, ydoc, props.extensions]); useEffect(() => { if ((!hasLocalPersistence || isLocalSynced) && isRemoteSynced) { void onSynced?.(); } }, [onSynced, hasLocalPersistence, isLocalSynced, isRemoteSynced]); // Disconnect the realtime connection while idle. `isIdle` also checks for // page visibility and will immediately disconnect when a tab is hidden. useEffect(() => { if (!remoteProvider) { return; } if ( isIdle && !isVisible && remoteProvider.status === WebSocketStatus.Connected ) { remoteProvider.disconnect(); } if ( (!isIdle || isVisible) && remoteProvider.status === WebSocketStatus.Disconnected ) { void remoteProvider.connect(); } }, [remoteProvider, isIdle, isVisible]); // Certain emoji combinations trigger this error in YJS, while waiting for a fix // we must prevent the user from continuing to edit as their changes will not // be persisted. See: https://github.com/yjs/yjs/issues/303 useEffect(() => { function onUnhandledError(event: ErrorEvent) { if (event.message.includes("URIError: URI malformed")) { toast.error( t( "Sorry, the last change could not be persisted – please reload the page" ) ); } } window.addEventListener("error", onUnhandledError); return () => window.removeEventListener("error", onUnhandledError); }, [t]); if (!remoteProvider) { 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 isLocalReady = !hasLocalPersistence || isLocalSynced; const showCache = !isLocalReady && !isRemoteSynced; return ( <> {showCache && ( )} ); } export default forwardRef(MultiplayerEditor);