diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx index c308a70d..051b523d 100644 --- a/src/excalidraw-app/collab/CollabWrapper.tsx +++ b/src/excalidraw-app/collab/CollabWrapper.tsx @@ -19,12 +19,16 @@ import { } from "../app_constants"; import { decryptAESGEM, - generateCollaborationLink, - getCollaborationLinkData, + generateCollaborationLinkData, + getCollaborationLink, SocketUpdateDataSource, SOCKET_SERVER, } from "../data"; -import { isSavedToFirebase, saveToFirebase } from "../data/firebase"; +import { + isSavedToFirebase, + loadFromFirebase, + saveToFirebase, +} from "../data/firebase"; import { importUsernameFromLocalStorage, saveUsernameToLocalStorage, @@ -33,9 +37,9 @@ import { import Portal from "./Portal"; import RoomDialog from "./RoomDialog"; import { createInverseContext } from "../../createInverseContext"; +import { t } from "../../i18n"; interface CollabState { - isCollaborating: boolean; modalIsShown: boolean; errorMessage: string; username: string; @@ -45,7 +49,8 @@ interface CollabState { type CollabInstance = InstanceType; export interface CollabAPI { - isCollaborating: CollabState["isCollaborating"]; + /** function so that we can access the latest value from stale callbacks */ + isCollaborating: () => boolean; username: CollabState["username"]; onPointerUpdate: CollabInstance["onPointerUpdate"]; initializeSocketClient: CollabInstance["initializeSocketClient"]; @@ -72,6 +77,8 @@ export { CollabContext, CollabContextConsumer }; class CollabWrapper extends PureComponent { portal: Portal; excalidrawAPI: Props["excalidrawAPI"]; + isCollaborating: boolean = false; + private socketInitializationTimer?: NodeJS.Timeout; private lastBroadcastedOrReceivedSceneVersion: number = -1; private collaborators = new Map(); @@ -79,7 +86,6 @@ class CollabWrapper extends PureComponent { constructor(props: Props) { super(props); this.state = { - isCollaborating: false, modalIsShown: false, errorMessage: "", username: importUsernameFromLocalStorage() || "", @@ -113,15 +119,16 @@ class CollabWrapper extends PureComponent { } private onUnload = () => { - this.destroySocketClient(); + this.destroySocketClient({ isUnload: true }); }; private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { const syncableElements = getSyncableElements( this.getSceneElementsIncludingDeleted(), ); + if ( - this.state.isCollaborating && + this.isCollaborating && !isSavedToFirebase(this.portal, syncableElements) ) { // this won't run in time if user decides to leave the site, but @@ -133,7 +140,7 @@ class CollabWrapper extends PureComponent { event.returnValue = ""; } - if (this.state.isCollaborating || this.portal.roomId) { + if (this.isCollaborating || this.portal.roomId) { try { localStorage?.setItem( STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG, @@ -159,143 +166,175 @@ class CollabWrapper extends PureComponent { }; openPortal = async () => { - window.history.pushState({}, APP_NAME, await generateCollaborationLink()); - const elements = this.excalidrawAPI.getSceneElements(); - // remove deleted elements from elements array & history 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.history.clear(); - this.excalidrawAPI.updateScene({ - elements, - commitToHistory: true, - }); - return this.initializeSocketClient(); + return this.initializeSocketClient(null); }; closePortal = () => { this.saveCollabRoomToFirebase(); - window.history.pushState({}, APP_NAME, window.location.origin); - this.destroySocketClient(); + if (window.confirm(t("alerts.collabStopOverridePrompt"))) { + window.history.pushState({}, APP_NAME, window.location.origin); + this.destroySocketClient(); + } }; - private destroySocketClient = () => { - this.collaborators = new Map(); - this.excalidrawAPI.updateScene({ - collaborators: this.collaborators, - }); - this.setState({ - isCollaborating: false, - activeRoomLink: "", - }); + private destroySocketClient = (opts?: { isUnload: boolean }) => { + if (!opts?.isUnload) { + this.collaborators = new Map(); + this.excalidrawAPI.updateScene({ + collaborators: this.collaborators, + }); + this.setState({ + activeRoomLink: "", + }); + this.isCollaborating = false; + } this.portal.close(); }; - private initializeSocketClient = async (): Promise => { + private initializeSocketClient = async ( + existingRoomLinkData: null | { roomId: string; roomKey: string }, + ): Promise => { if (this.portal.socket) { return null; } - const scenePromise = resolvablePromise(); + let roomId; + let roomKey; - const roomMatch = getCollaborationLinkData(window.location.href); - - if (roomMatch) { - const roomId = roomMatch[1]; - const roomKey = roomMatch[2]; - - // fallback in case you're not alone in the room but still don't receive - // initial SCENE_UPDATE message - this.socketInitializationTimer = setTimeout(() => { - this.initializeSocket(); - scenePromise.resolve(null); - }, INITIAL_SCENE_UPDATE_TIMEOUT); - - const { default: socketIOClient }: any = await import( - /* webpackChunkName: "socketIoClient" */ "socket.io-client" + if (existingRoomLinkData) { + ({ roomId, roomKey } = existingRoomLinkData); + } else { + ({ roomId, roomKey } = await generateCollaborationLinkData()); + window.history.pushState( + {}, + APP_NAME, + getCollaborationLink({ roomId, roomKey }), ); - - this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey); - - // 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 decryptAESGEM( - encryptedData, - this.portal.roomKey, - iv, - ); - - switch (decryptedData.type) { - case "INVALID_RESPONSE": - return; - case SCENE.INIT: { - if (!this.portal.socketInitialized) { - const remoteElements = decryptedData.payload.elements; - const reconciledElements = this.reconcileElements( - remoteElements, - ); - this.handleRemoteSceneUpdate(reconciledElements, { - init: true, - }); - this.initializeSocket(); - scenePromise.resolve({ elements: reconciledElements }); - } - break; - } - case SCENE.UPDATE: - this.handleRemoteSceneUpdate( - this.reconcileElements(decryptedData.payload.elements), - ); - break; - case "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; - - const collaborators = new Map(this.collaborators); - const user = collaborators.get(socketId) || {}!; - user.pointer = pointer; - user.button = button; - user.selectedElementIds = selectedElementIds; - user.username = username; - collaborators.set(socketId, user); - this.excalidrawAPI.updateScene({ - collaborators, - }); - break; - } - } - }, - ); - this.portal.socket!.on("first-in-room", () => { - if (this.portal.socket) { - this.portal.socket.off("first-in-room"); - } - this.initializeSocket(); - scenePromise.resolve(null); - }); - - this.setState({ - isCollaborating: true, - activeRoomLink: window.location.href, - }); - - return scenePromise; } - return null; + const scenePromise = resolvablePromise(); + + this.isCollaborating = true; + + const { default: socketIOClient }: any = await import( + /* webpackChunkName: "socketIoClient" */ "socket.io-client" + ); + + this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey); + + if (existingRoomLinkData) { + this.excalidrawAPI.resetScene(); + + try { + const elements = await loadFromFirebase( + roomId, + roomKey, + this.portal.socket, + ); + if (elements) { + scenePromise.resolve({ + elements, + }); + } + } catch (error) { + // log the error and move on. other peers will sync us the scene. + console.error(error); + } + } else { + const elements = this.excalidrawAPI.getSceneElements(); + // remove deleted elements from elements array & history 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.history.clear(); + this.excalidrawAPI.updateScene({ + elements, + commitToHistory: true, + }); + } + + // fallback in case you're not alone in the room but still don't receive + // initial SCENE_UPDATE message + this.socketInitializationTimer = setTimeout(() => { + this.initializeSocket(); + scenePromise.resolve(null); + }, 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 decryptAESGEM( + encryptedData, + this.portal.roomKey, + iv, + ); + + switch (decryptedData.type) { + case "INVALID_RESPONSE": + return; + case SCENE.INIT: { + if (!this.portal.socketInitialized) { + this.initializeSocket(); + const remoteElements = decryptedData.payload.elements; + const reconciledElements = this.reconcileElements(remoteElements); + this.handleRemoteSceneUpdate(reconciledElements, { + init: true, + }); + // noop if already resolved via init from firebase + scenePromise.resolve({ elements: reconciledElements }); + } + break; + } + case SCENE.UPDATE: + this.handleRemoteSceneUpdate( + this.reconcileElements(decryptedData.payload.elements), + ); + break; + case "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; + + const collaborators = new Map(this.collaborators); + const user = collaborators.get(socketId) || {}!; + user.pointer = pointer; + user.button = button; + user.selectedElementIds = selectedElementIds; + user.username = username; + collaborators.set(socketId, user); + this.excalidrawAPI.updateScene({ + collaborators, + }); + break; + } + } + }, + ); + + this.portal.socket!.on("first-in-room", () => { + if (this.portal.socket) { + this.portal.socket.off("first-in-room"); + } + this.initializeSocket(); + scenePromise.resolve(null); + }); + + this.setState({ + activeRoomLink: window.location.href, + }); + + return scenePromise; }; private initializeSocket = () => { @@ -480,9 +519,11 @@ class CollabWrapper extends PureComponent { /** Getter of context value. Returned object is stable. */ getContextValue = (): CollabAPI => { - this.contextValue = this.contextValue || ({} as CollabAPI); + if (!this.contextValue) { + this.contextValue = {} as CollabAPI; + } - this.contextValue.isCollaborating = this.state.isCollaborating; + this.contextValue.isCollaborating = () => this.isCollaborating; this.contextValue.username = this.state.username; this.contextValue.onPointerUpdate = this.onPointerUpdate; this.contextValue.initializeSocketClient = this.initializeSocketClient; diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx index 92086a27..72ee6023 100644 --- a/src/excalidraw-app/collab/Portal.tsx +++ b/src/excalidraw-app/collab/Portal.tsx @@ -122,7 +122,7 @@ class Portal { data as SocketUpdateData, ); - if (syncAll && this.collab.state.isCollaborating) { + if (syncAll && this.collab.isCollaborating) { await Promise.all([ broadcastPromise, this.collab.saveCollabRoomToFirebase(syncableElements), diff --git a/src/excalidraw-app/data/firebase.ts b/src/excalidraw-app/data/firebase.ts index 5e2163df..57148319 100644 --- a/src/excalidraw-app/data/firebase.ts +++ b/src/excalidraw-app/data/firebase.ts @@ -148,6 +148,7 @@ export const saveToFirebase = async ( export const loadFromFirebase = async ( roomId: string, roomKey: string, + socket: SocketIOClient.Socket | null, ): Promise => { const firebase = await getFirebase(); const db = firebase.firestore(); @@ -160,5 +161,12 @@ export const loadFromFirebase = async ( const storedScene = doc.data() as FirebaseStoredScene; const ciphertext = storedScene.ciphertext.toUint8Array(); const iv = storedScene.iv.toUint8Array(); - return restoreElements(await decryptElements(roomKey, iv, ciphertext)); + + const elements = await decryptElements(roomKey, iv, ciphertext); + + if (socket) { + firebaseSceneVersionCache.set(socket, getSceneVersion(elements)); + } + + return restoreElements(elements); }; diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts index 9ec7b257..1d4aaf60 100644 --- a/src/excalidraw-app/data/index.ts +++ b/src/excalidraw-app/data/index.ts @@ -125,17 +125,27 @@ export const decryptAESGEM = async ( }; export const getCollaborationLinkData = (link: string) => { - if (link.length === 0) { - return; - } const hash = new URL(link).hash; - return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/); + const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/); + return match ? { roomId: match[1], roomKey: match[2] } : null; }; -export const generateCollaborationLink = async () => { - const id = await generateRandomID(); - const key = await generateEncryptionKey(); - return `${window.location.origin}${window.location.pathname}#room=${id},${key}`; +export const generateCollaborationLinkData = async () => { + const roomId = await generateRandomID(); + const roomKey = await generateEncryptionKey(); + + if (!roomKey) { + throw new Error("Couldn't generate room key"); + } + + return { roomId, roomKey }; +}; + +export const getCollaborationLink = (data: { + roomId: string; + roomKey: string; +}) => { + return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`; }; export const getImportedKey = (key: string, usage: KeyUsage) => diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index fcc7f45f..32187ec8 100644 --- a/src/excalidraw-app/index.tsx +++ b/src/excalidraw-app/index.tsx @@ -39,11 +39,9 @@ import CollabWrapper, { } from "./collab/CollabWrapper"; import { LanguageList } from "./components/LanguageList"; import { exportToBackend, getCollaborationLinkData, loadScene } from "./data"; -import { loadFromFirebase } from "./data/firebase"; import { importFromLocalStorage, saveToLocalStorage, - STORAGE_KEYS, } from "./data/localStorage"; const languageDetector = new LanguageDetector(); @@ -66,50 +64,9 @@ const onBlur = () => { saveDebounced.flush(); }; -const shouldForceLoadScene = ( - scene: ResolutionType, -): boolean => { - if (!scene.elements.length) { - return true; - } - - const roomMatch = getCollaborationLinkData(window.location.href); - - if (!roomMatch) { - return false; - } - - const roomId = roomMatch[1]; - - let collabForceLoadFlag; - try { - collabForceLoadFlag = localStorage?.getItem( - STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG, - ); - } catch {} - - if (collabForceLoadFlag) { - try { - const { - room: previousRoom, - timestamp, - }: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag); - // if loading same room as the one previously unloaded within 15sec - // force reload without prompting - if (previousRoom === roomId && Date.now() - timestamp < 15000) { - return true; - } - } catch {} - } - return false; -}; - -type Scene = ImportedDataState & { commitToHistory: boolean }; - const initializeScene = async (opts: { - resetScene: ExcalidrawImperativeAPI["resetScene"]; - initializeSocketClient: CollabAPI["initializeSocketClient"]; -}): Promise => { + collabAPI: CollabAPI; +}): Promise => { const searchParams = new URLSearchParams(window.location.search); const id = searchParams.get("id"); const jsonMatch = window.location.hash.match( @@ -120,20 +77,17 @@ const initializeScene = async (opts: { let scene = await loadScene(null, null, initialData); - let isCollabScene = !!getCollaborationLinkData(window.location.href); - const isExternalScene = !!(id || jsonMatch || isCollabScene); + let roomLinkData = getCollaborationLinkData(window.location.href); + const isExternalScene = !!(id || jsonMatch || roomLinkData); if (isExternalScene) { - if ( - shouldForceLoadScene(scene) || - window.confirm(t("alerts.loadSceneOverridePrompt")) - ) { + if (roomLinkData || window.confirm(t("alerts.loadSceneOverridePrompt"))) { // Backwards compatibility with legacy url format if (id) { scene = await loadScene(id, null, initialData); } else if (jsonMatch) { scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData); } - if (!isCollabScene) { + if (!roomLinkData) { window.history.replaceState({}, APP_NAME, window.location.origin); } } else { @@ -150,38 +104,12 @@ const initializeScene = async (opts: { }); } - isCollabScene = false; + roomLinkData = null; window.history.replaceState({}, APP_NAME, window.location.origin); } } - if (isCollabScene) { - // when joining a room we don't want user's local scene data to be merged - // into the remote scene - opts.resetScene(); - const scenePromise = opts.initializeSocketClient(); - - try { - const [, roomId, roomKey] = getCollaborationLinkData( - window.location.href, - )!; - const elements = await loadFromFirebase(roomId, roomKey); - if (elements) { - return { - elements, - commitToHistory: true, - }; - } - - return { - ...(await scenePromise), - commitToHistory: true, - }; - } catch (error) { - // log the error and move on. other peers will sync us the scene. - console.error(error); - } - - return null; + if (roomLinkData) { + return opts.collabAPI.initializeSocketClient(roomLinkData); } else if (scene) { return scene; } @@ -242,24 +170,16 @@ function ExcalidrawWrapper() { return; } - initializeScene({ - resetScene: excalidrawAPI.resetScene, - initializeSocketClient: collabAPI.initializeSocketClient, - }).then((scene) => { + initializeScene({ collabAPI }).then((scene) => { initialStatePromiseRef.current.promise.resolve(scene); }); const onHashChange = (_: HashChangeEvent) => { - if (window.location.hash.length > 1) { - initializeScene({ - resetScene: excalidrawAPI.resetScene, - initializeSocketClient: collabAPI.initializeSocketClient, - }).then((scene) => { - if (scene) { - excalidrawAPI.updateScene(scene); - } - }); - } + initializeScene({ collabAPI }).then((scene) => { + if (scene) { + excalidrawAPI.updateScene(scene); + } + }); }; const titleTimeout = setTimeout( @@ -285,9 +205,13 @@ function ExcalidrawWrapper() { elements: readonly ExcalidrawElement[], appState: AppState, ) => { - saveDebounced(elements, appState); - if (collabAPI?.isCollaborating) { + if (collabAPI?.isCollaborating()) { collabAPI.broadcastElements(elements); + } else { + // collab scenes are persisted to the server, so we don't have to persist + // them locally, which has the added benefit of not overwriting whatever + // the user was working on before joining + saveDebounced(elements, appState); } }; @@ -352,7 +276,7 @@ function ExcalidrawWrapper() { initialData={initialStatePromiseRef.current.promise} user={{ name: collabAPI?.username }} onCollabButtonClick={collabAPI?.onCollabButtonClick} - isCollaborating={collabAPI?.isCollaborating} + isCollaborating={collabAPI?.isCollaborating()} onPointerUpdate={collabAPI?.onPointerUpdate} onExportToBackend={onExportToBackend} renderFooter={renderFooter} diff --git a/src/locales/en.json b/src/locales/en.json index f6704ec2..88580089 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -136,6 +136,7 @@ "decryptFailed": "Couldn't decrypt data.", "uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.", "loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?", + "collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)", "errorLoadingLibrary": "There was an error loading the third party library.", "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?", "imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",