import throttle from "lodash.throttle"; import { PureComponent } from "react"; import type { ExcalidrawImperativeAPI, SocketId, } from "../../packages/excalidraw/types"; import { ErrorDialog } from "../../packages/excalidraw/components/ErrorDialog"; import { APP_NAME, ENV, EVENT } from "../../packages/excalidraw/constants"; import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import type { ExcalidrawElement, InitializedExcalidrawImageElement, OrderedExcalidrawElement, } from "../../packages/excalidraw/element/types"; import { StoreAction, getSceneVersion, restoreElements, zoomToFitBounds, reconcileElements, } from "../../packages/excalidraw"; import type { Collaborator, Gesture } from "../../packages/excalidraw/types"; import { assertNever, preventUnload, resolvablePromise, throttleRAF, } from "../../packages/excalidraw/utils"; import { CURSOR_SYNC_TIMEOUT, FILE_UPLOAD_MAX_BYTES, FIREBASE_STORAGE_PREFIXES, INITIAL_SCENE_UPDATE_TIMEOUT, LOAD_IMAGES_TIMEOUT, WS_SUBTYPES, SYNC_FULL_SCENE_INTERVAL_MS, WS_EVENTS, } from "../app_constants"; import type { SocketUpdateDataSource, SyncableExcalidrawElement, } from "../data"; import { generateCollaborationLinkData, getCollaborationLink, getSyncableElements, } from "../data"; import { isSavedToFirebase } from "../data/firebase"; import { importUsernameFromLocalStorage, saveUsernameToLocalStorage, } from "../data/localStorage"; import Portal from "./Portal"; import { t } from "../../packages/excalidraw/i18n"; import { UserIdleState } from "../../packages/excalidraw/types"; import { IDLE_THRESHOLD, ACTIVE_THRESHOLD, } from "../../packages/excalidraw/constants"; import { encodeFilesForUpload, FileManager, updateStaleImageStatuses, } from "../data/FileManager"; import { AbortError } from "../../packages/excalidraw/errors"; import { isImageElement, isInitializedImageElement, } from "../../packages/excalidraw/element/typeChecks"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { decryptData } from "../../packages/excalidraw/data/encryption"; import { resetBrowserStateVersions } from "../data/tabSync"; import { LocalData } from "../data/LocalData"; import { atom } from "jotai"; import { appJotaiStore } from "../app-jotai"; import type { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { getStorageBackend } from "../data/config"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; import { collabErrorIndicatorAtom } from "./CollabError"; import type { ReconciledExcalidrawElement, RemoteExcalidrawElement, } from "../../packages/excalidraw/data/reconcile"; export const collabAPIAtom = atom(null); export const isCollaboratingAtom = atom(false); export const isOfflineAtom = atom(false); interface CollabState { errorMessage: string | null; /** errors related to saving */ dialogNotifiedErrors: Record; username: string; activeRoomLink: string | null; } export const activeRoomLinkAtom = atom(null); type CollabInstance = InstanceType; export interface CollabAPI { /** function so that we can access the latest value from stale callbacks */ isCollaborating: () => boolean; onPointerUpdate: CollabInstance["onPointerUpdate"]; startCollaboration: CollabInstance["startCollaboration"]; stopCollaboration: CollabInstance["stopCollaboration"]; syncElements: CollabInstance["syncElements"]; fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; setUsername: CollabInstance["setUsername"]; getUsername: CollabInstance["getUsername"]; getActiveRoomLink: CollabInstance["getActiveRoomLink"]; setCollabError: CollabInstance["setErrorDialog"]; } interface CollabProps { excalidrawAPI: ExcalidrawImperativeAPI; } class Collab extends PureComponent { portal: Portal; fileManager: FileManager; excalidrawAPI: CollabProps["excalidrawAPI"]; activeIntervalId: number | null; idleTimeoutId: number | null; private socketInitializationTimer?: number; private lastBroadcastedOrReceivedSceneVersion: number = -1; private collaborators = new Map(); constructor(props: CollabProps) { super(props); this.state = { errorMessage: null, dialogNotifiedErrors: {}, username: importUsernameFromLocalStorage() || "", activeRoomLink: null, }; this.portal = new Portal(this); this.fileManager = new FileManager({ getFiles: async (fileIds) => { const { roomId, roomKey } = this.portal; if (!roomId || !roomKey) { throw new AbortError(); } const storageBackend = await getStorageBackend(); return storageBackend.loadFilesFromStorageBackend( `files/rooms/${roomId}`, roomKey, fileIds, ); }, saveFiles: async ({ addedFiles }) => { const { roomId, roomKey } = this.portal; if (!roomId || !roomKey) { throw new AbortError(); } const storageBackend = await getStorageBackend(); return storageBackend.saveFilesToStorageBackend({ prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`, files: await encodeFilesForUpload({ files: addedFiles, encryptionKey: roomKey, maxBytes: FILE_UPLOAD_MAX_BYTES, }), }); }, }); this.excalidrawAPI = props.excalidrawAPI; this.activeIntervalId = null; this.idleTimeoutId = null; } private onUmmount: (() => void) | null = null; componentDidMount() { window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.addEventListener("online", this.onOfflineStatusToggle); window.addEventListener("offline", this.onOfflineStatusToggle); window.addEventListener(EVENT.UNLOAD, this.onUnload); const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => { this.portal.socket && this.portal.broadcastUserFollowed(payload); }); const throttledRelayUserViewportBounds = throttleRAF( this.relayVisibleSceneBounds, ); const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() => throttledRelayUserViewportBounds(), ); this.onUmmount = () => { unsubOnUserFollow(); unsubOnScrollChange(); }; this.onOfflineStatusToggle(); const collabAPI: CollabAPI = { isCollaborating: this.isCollaborating, onPointerUpdate: this.onPointerUpdate, startCollaboration: this.startCollaboration, syncElements: this.syncElements, fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, stopCollaboration: this.stopCollaboration, setUsername: this.setUsername, getUsername: this.getUsername, getActiveRoomLink: this.getActiveRoomLink, setCollabError: this.setErrorDialog, }; appJotaiStore.set(collabAPIAtom, collabAPI); if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { window.collab = window.collab || ({} as Window["collab"]); Object.defineProperties(window, { collab: { configurable: true, value: this, }, }); } } onOfflineStatusToggle = () => { appJotaiStore.set(isOfflineAtom, !window.navigator.onLine); }; componentWillUnmount() { window.removeEventListener("online", this.onOfflineStatusToggle); window.removeEventListener("offline", this.onOfflineStatusToggle); window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.removeEventListener(EVENT.UNLOAD, this.onUnload); window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove); window.removeEventListener( EVENT.VISIBILITY_CHANGE, this.onVisibilityChange, ); if (this.activeIntervalId) { window.clearInterval(this.activeIntervalId); this.activeIntervalId = null; } if (this.idleTimeoutId) { window.clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } this.onUmmount?.(); } isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; private setIsCollaborating = (isCollaborating: boolean) => { appJotaiStore.set(isCollaboratingAtom, isCollaborating); }; private onUnload = () => { this.destroySocketClient({ isUnload: true }); }; private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { const syncableElements = getSyncableElements( this.getSceneElementsIncludingDeleted(), ); if ( this.isCollaborating() && (this.fileManager.shouldPreventUnload(syncableElements) || !isSavedToFirebase(this.portal, syncableElements)) ) { // this won't run in time if user decides to leave the site, but // the purpose is to run in immediately after user decides to stay this.saveCollabRoomToFirebase(syncableElements); preventUnload(event); } }); saveCollabRoomToFirebase = async ( syncableElements: readonly SyncableExcalidrawElement[], ) => { try { const storageBackend = await getStorageBackend(); const storedElements = await storageBackend.saveToStorageBackend( this.portal, syncableElements, this.excalidrawAPI.getAppState(), ); this.resetErrorIndicator(); if (this.isCollaborating() && storedElements) { this.handleRemoteSceneUpdate(this._reconcileElements(storedElements)); } } catch (error: any) { const errorMessage = /is longer than.*?bytes/.test(error.message) ? t("errors.collabSaveFailed_sizeExceeded") : t("errors.collabSaveFailed"); if ( !this.state.dialogNotifiedErrors[errorMessage] || !this.isCollaborating() ) { this.setErrorDialog(errorMessage); this.setState({ dialogNotifiedErrors: { ...this.state.dialogNotifiedErrors, [errorMessage]: true, }, }); } if (this.isCollaborating()) { this.setErrorIndicator(errorMessage); } console.error(error); } }; stopCollaboration = (keepRemoteState = true) => { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); this.resetErrorIndicator(true); this.saveCollabRoomToFirebase( getSyncableElements( this.excalidrawAPI.getSceneElementsIncludingDeleted(), ), ); if (this.portal.socket && this.fallbackInitializationHandler) { this.portal.socket.off( "connect_error", this.fallbackInitializationHandler, ); } if (!keepRemoteState) { LocalData.fileStorage.reset(); this.destroySocketClient(); } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) { // hack to ensure that we prefer we disregard any new browser state // that could have been saved in other tabs while we were collaborating resetBrowserStateVersions(); window.history.pushState({}, APP_NAME, window.location.origin); this.destroySocketClient(); LocalData.fileStorage.reset(); const elements = this.excalidrawAPI .getSceneElementsIncludingDeleted() .map((element) => { if (isImageElement(element) && element.status === "saved") { return newElementWith(element, { status: "pending" }); } return element; }); this.excalidrawAPI.updateScene({ elements, storeAction: StoreAction.UPDATE, }); } }; private destroySocketClient = (opts?: { isUnload: boolean }) => { this.lastBroadcastedOrReceivedSceneVersion = -1; this.portal.close(); this.fileManager.reset(); if (!opts?.isUnload) { this.setIsCollaborating(false); this.setActiveRoomLink(null); this.collaborators = new Map(); this.excalidrawAPI.updateScene({ collaborators: this.collaborators, }); LocalData.resumeSave("collaboration"); } }; private fetchImageFilesFromFirebase = async (opts: { elements: readonly ExcalidrawElement[]; /** * Indicates whether to fetch files that are errored or pending and older * than 10 seconds. * * Use this as a mechanism to fetch files which may be ok but for some * reason their status was not updated correctly. */ forceFetchFiles?: boolean; }) => { const unfetchedImages = opts.elements .filter((element) => { return ( isInitializedImageElement(element) && !this.fileManager.isFileHandled(element.fileId) && !element.isDeleted && (opts.forceFetchFiles ? element.status !== "pending" || Date.now() - element.updated > 10000 : element.status === "saved") ); }) .map((element) => (element as InitializedExcalidrawImageElement).fileId); return await this.fileManager.getFiles(unfetchedImages); }; private decryptPayload = async ( iv: Uint8Array, encryptedData: ArrayBuffer, decryptionKey: string, ): Promise> => { try { const decrypted = await decryptData(iv, encryptedData, decryptionKey); const decodedData = new TextDecoder("utf-8").decode( new Uint8Array(decrypted), ); return JSON.parse(decodedData); } catch (error) { window.alert(t("alerts.decryptFailed")); console.error(error); return { type: WS_SUBTYPES.INVALID_RESPONSE, }; } }; private fallbackInitializationHandler: null | (() => any) = null; startCollaboration = async ( existingRoomLinkData: null | { roomId: string; roomKey: string }, ) => { if (!this.state.username) { import("@excalidraw/random-username").then(({ getRandomUsername }) => { const username = getRandomUsername(); this.setUsername(username); }); } if (this.portal.socket) { return null; } let roomId; let roomKey; if (existingRoomLinkData) { ({ roomId, roomKey } = existingRoomLinkData); } else { ({ roomId, roomKey } = await generateCollaborationLinkData()); window.history.pushState( {}, APP_NAME, getCollaborationLink({ roomId, roomKey }), ); } // TODO: `ImportedDataState` type here seems abused const scenePromise = resolvablePromise< | ImportedDataState | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] }) | null >(); this.setIsCollaborating(true); LocalData.pauseSave("collaboration"); const { default: socketIOClient } = await import( /* webpackChunkName: "socketIoClient" */ "socket.io-client" ); const fallbackInitializationHandler = () => { this.initializeRoom({ roomLinkData: existingRoomLinkData, fetchScene: true, }).then((scene) => { scenePromise.resolve(scene); }); }; this.fallbackInitializationHandler = fallbackInitializationHandler; try { this.portal.socket = this.portal.open( socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, { transports: ["websocket", "polling"], }), roomId, roomKey, ); this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); this.setErrorDialog(error.message); return null; } if (!existingRoomLinkData) { const elements = this.excalidrawAPI.getSceneElements().map((element) => { if (isImageElement(element) && element.status === "saved") { return newElementWith(element, { status: "pending" }); } return element; }); // remove deleted elements from elements array to ensure we don't // expose potentially sensitive user data in case user manually deletes // existing elements (or clears scene), which would otherwise be persisted // to database even if deleted before creating the room. this.excalidrawAPI.updateScene({ elements, storeAction: StoreAction.UPDATE, }); this.saveCollabRoomToFirebase(getSyncableElements(elements)); } // fallback in case you're not alone in the room but still don't receive // initial SCENE_INIT message this.socketInitializationTimer = window.setTimeout( fallbackInitializationHandler, INITIAL_SCENE_UPDATE_TIMEOUT, ); // All socket listeners are moving to Portal this.portal.socket.on( "client-broadcast", async (encryptedData: ArrayBuffer, iv: Uint8Array) => { if (!this.portal.roomKey) { return; } const decryptedData = await this.decryptPayload( iv, encryptedData, this.portal.roomKey, ); switch (decryptedData.type) { case WS_SUBTYPES.INVALID_RESPONSE: return; case WS_SUBTYPES.INIT: { if (!this.portal.socketInitialized) { this.initializeRoom({ fetchScene: false }); const remoteElements = decryptedData.payload.elements; const reconciledElements = this._reconcileElements(remoteElements); this.handleRemoteSceneUpdate(reconciledElements); // noop if already resolved via init from firebase scenePromise.resolve({ elements: reconciledElements, scrollToContent: true, }); } break; } case WS_SUBTYPES.UPDATE: this.handleRemoteSceneUpdate( this._reconcileElements(decryptedData.payload.elements), ); break; case WS_SUBTYPES.MOUSE_LOCATION: { const { pointer, button, username, selectedElementIds } = decryptedData.payload; const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] = decryptedData.payload.socketId || // @ts-ignore legacy, see #2094 (#2097) decryptedData.payload.socketID; this.updateCollaborator(socketId, { pointer, button, selectedElementIds, username, }); break; } case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: { const { sceneBounds, socketId } = decryptedData.payload; const appState = this.excalidrawAPI.getAppState(); // we're not following the user // (shouldn't happen, but could be late message or bug upstream) if (appState.userToFollow?.socketId !== socketId) { console.warn( `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`, ); return; } // cross-follow case, ignore updates in this case if ( appState.userToFollow && appState.followedBy.has(appState.userToFollow.socketId) ) { return; } this.excalidrawAPI.updateScene({ appState: zoomToFitBounds({ appState, bounds: sceneBounds, fitToViewport: true, viewportZoomFactor: 1, }).appState, }); break; } case WS_SUBTYPES.IDLE_STATUS: { const { userState, socketId, username } = decryptedData.payload; this.updateCollaborator(socketId, { userState, username, }); break; } default: { assertNever(decryptedData, null); } } }, ); this.portal.socket.on("first-in-room", async () => { if (this.portal.socket) { this.portal.socket.off("first-in-room"); } const sceneData = await this.initializeRoom({ fetchScene: true, roomLinkData: existingRoomLinkData, }); scenePromise.resolve(sceneData); }); this.portal.socket.on( WS_EVENTS.USER_FOLLOW_ROOM_CHANGE, (followedBy: SocketId[]) => { this.excalidrawAPI.updateScene({ appState: { followedBy: new Set(followedBy) }, }); this.relayVisibleSceneBounds({ force: true }); }, ); this.initializeIdleDetector(); this.setActiveRoomLink(window.location.href); return scenePromise; }; private initializeRoom = async ({ fetchScene, roomLinkData, }: | { fetchScene: true; roomLinkData: { roomId: string; roomKey: string } | null; } | { fetchScene: false; roomLinkData?: null }) => { clearTimeout(this.socketInitializationTimer!); if (this.portal.socket && this.fallbackInitializationHandler) { this.portal.socket.off( "connect_error", this.fallbackInitializationHandler, ); } if (fetchScene && roomLinkData && this.portal.socket) { this.excalidrawAPI.resetScene(); try { const storageBackend = await getStorageBackend(); const elements = await storageBackend.loadFromStorageBackend( roomLinkData.roomId, roomLinkData.roomKey, this.portal.socket, ); if (elements) { this.setLastBroadcastedOrReceivedSceneVersion( getSceneVersion(elements), ); return { elements, scrollToContent: true, }; } } catch (error: any) { // log the error and move on. other peers will sync us the scene. console.error(error); } finally { this.portal.socketInitialized = true; } } else { this.portal.socketInitialized = true; } return null; }; private _reconcileElements = ( remoteElements: readonly ExcalidrawElement[], ): ReconciledExcalidrawElement[] => { const localElements = this.getSceneElementsIncludingDeleted(); const appState = this.excalidrawAPI.getAppState(); const restoredRemoteElements = restoreElements(remoteElements, null); const reconciledElements = reconcileElements( localElements, restoredRemoteElements as RemoteExcalidrawElement[], appState, ); // Avoid broadcasting to the rest of the collaborators the scene // we just received! // Note: this needs to be set before updating the scene as it // synchronously calls render. this.setLastBroadcastedOrReceivedSceneVersion( getSceneVersion(reconciledElements), ); return reconciledElements; }; private loadImageFiles = throttle(async () => { const { loadedFiles, erroredFiles } = await this.fetchImageFilesFromFirebase({ elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(), }); this.excalidrawAPI.addFiles(loadedFiles); updateStaleImageStatuses({ excalidrawAPI: this.excalidrawAPI, erroredFiles, elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(), }); }, LOAD_IMAGES_TIMEOUT); private handleRemoteSceneUpdate = ( elements: ReconciledExcalidrawElement[], ) => { this.excalidrawAPI.updateScene({ elements, storeAction: StoreAction.UPDATE, }); this.loadImageFiles(); }; private onPointerMove = () => { if (this.idleTimeoutId) { window.clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD); if (!this.activeIntervalId) { this.activeIntervalId = window.setInterval( this.reportActive, ACTIVE_THRESHOLD, ); } }; private onVisibilityChange = () => { if (document.hidden) { if (this.idleTimeoutId) { window.clearTimeout(this.idleTimeoutId); this.idleTimeoutId = null; } if (this.activeIntervalId) { window.clearInterval(this.activeIntervalId); this.activeIntervalId = null; } this.onIdleStateChange(UserIdleState.AWAY); } else { this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD); this.activeIntervalId = window.setInterval( this.reportActive, ACTIVE_THRESHOLD, ); this.onIdleStateChange(UserIdleState.ACTIVE); } }; private reportIdle = () => { this.onIdleStateChange(UserIdleState.IDLE); if (this.activeIntervalId) { window.clearInterval(this.activeIntervalId); this.activeIntervalId = null; } }; private reportActive = () => { this.onIdleStateChange(UserIdleState.ACTIVE); }; private initializeIdleDetector = () => { document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove); document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange); }; setCollaborators(sockets: SocketId[]) { const collaborators: InstanceType["collaborators"] = new Map(); for (const socketId of sockets) { collaborators.set( socketId, Object.assign({}, this.collaborators.get(socketId), { isCurrentUser: socketId === this.portal.socket?.id, }), ); } this.collaborators = collaborators; this.excalidrawAPI.updateScene({ collaborators }); } updateCollaborator = (socketId: SocketId, updates: Partial) => { const collaborators = new Map(this.collaborators); const user: Mutable = Object.assign( {}, collaborators.get(socketId), updates, { isCurrentUser: socketId === this.portal.socket?.id, }, ); collaborators.set(socketId, user); this.collaborators = collaborators; this.excalidrawAPI.updateScene({ collaborators, }); }; public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { this.lastBroadcastedOrReceivedSceneVersion = version; }; public getLastBroadcastedOrReceivedSceneVersion = () => { return this.lastBroadcastedOrReceivedSceneVersion; }; public getSceneElementsIncludingDeleted = () => { return this.excalidrawAPI.getSceneElementsIncludingDeleted(); }; onPointerUpdate = throttle( (payload: { pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; pointersMap: Gesture["pointers"]; }) => { payload.pointersMap.size < 2 && this.portal.socket && this.portal.broadcastMouseLocation(payload); }, CURSOR_SYNC_TIMEOUT, ); relayVisibleSceneBounds = (props?: { force: boolean }) => { const appState = this.excalidrawAPI.getAppState(); if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) { this.portal.broadcastVisibleSceneBounds( { sceneBounds: getVisibleSceneBounds(appState), }, `follow@${this.portal.socket.id}`, ); } }; onIdleStateChange = (userState: UserIdleState) => { this.portal.broadcastIdleChange(userState); }; broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => { if ( getSceneVersion(elements) > this.getLastBroadcastedOrReceivedSceneVersion() ) { this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); this.queueBroadcastAllElements(); } }; syncElements = (elements: readonly OrderedExcalidrawElement[]) => { this.broadcastElements(elements); this.queueSaveToFirebase(); }; queueBroadcastAllElements = throttle(() => { this.portal.broadcastScene( WS_SUBTYPES.UPDATE, this.excalidrawAPI.getSceneElementsIncludingDeleted(), true, ); const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion(); const newVersion = Math.max( currentVersion, getSceneVersion(this.getSceneElementsIncludingDeleted()), ); this.setLastBroadcastedOrReceivedSceneVersion(newVersion); }, SYNC_FULL_SCENE_INTERVAL_MS); queueSaveToFirebase = throttle( () => { if (this.portal.socketInitialized) { this.saveCollabRoomToFirebase( getSyncableElements( this.excalidrawAPI.getSceneElementsIncludingDeleted(), ), ); } }, SYNC_FULL_SCENE_INTERVAL_MS, { leading: false }, ); setUsername = (username: string) => { this.setState({ username }); saveUsernameToLocalStorage(username); }; getUsername = () => this.state.username; setActiveRoomLink = (activeRoomLink: string | null) => { this.setState({ activeRoomLink }); appJotaiStore.set(activeRoomLinkAtom, activeRoomLink); }; getActiveRoomLink = () => this.state.activeRoomLink; setErrorIndicator = (errorMessage: string | null) => { appJotaiStore.set(collabErrorIndicatorAtom, { message: errorMessage, nonce: Date.now(), }); }; resetErrorIndicator = (resetDialogNotifiedErrors = false) => { appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 }); if (resetDialogNotifiedErrors) { this.setState({ dialogNotifiedErrors: {}, }); } }; setErrorDialog = (errorMessage: string | null) => { this.setState({ errorMessage, }); }; render() { const { errorMessage } = this.state; return ( <> {errorMessage != null && ( this.setErrorDialog(null)}> {errorMessage} )} ); } } declare global { interface Window { collab: InstanceType; } } if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { window.collab = window.collab || ({} as Window["collab"]); } export default Collab; export type TCollabClass = Collab;