Преглед на файлове

feat: don't store to LS during collab (#2909)

David Luzar преди 4 години
родител
ревизия
ce507b0a0b

+ 161 - 120
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<typeof CollabWrapper>;
 
 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<Props, CollabState> {
   portal: Portal;
   excalidrawAPI: Props["excalidrawAPI"];
+  isCollaborating: boolean = false;
+
   private socketInitializationTimer?: NodeJS.Timeout;
   private lastBroadcastedOrReceivedSceneVersion: number = -1;
   private collaborators = new Map<string, Collaborator>();
@@ -79,7 +86,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
   constructor(props: Props) {
     super(props);
     this.state = {
-      isCollaborating: false,
       modalIsShown: false,
       errorMessage: "",
       username: importUsernameFromLocalStorage() || "",
@@ -113,15 +119,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
   }
 
   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<Props, CollabState> {
       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<Props, CollabState> {
   };
 
   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<ImportedDataState | null> => {
+  private initializeSocketClient = async (
+    existingRoomLinkData: null | { roomId: string; roomKey: string },
+  ): Promise<ImportedDataState | null> => {
     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 }),
+      );
+    }
+
     const scenePromise = resolvablePromise<ImportedDataState | null>();
 
-    const roomMatch = getCollaborationLinkData(window.location.href);
+    this.isCollaborating = true;
 
-    if (roomMatch) {
-      const roomId = roomMatch[1];
-      const roomKey = roomMatch[2];
+    const { default: socketIOClient }: any = await import(
+      /* webpackChunkName: "socketIoClient" */ "socket.io-client"
+    );
 
-      // 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);
+    this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
 
-      const { default: socketIOClient }: any = await import(
-        /* webpackChunkName: "socketIoClient" */ "socket.io-client"
-      );
+    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,
+      });
+    }
 
-      this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
+    // 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,
+        );
 
-      // All socket listeners are moving to Portal
-      this.portal.socket!.on(
-        "client-broadcast",
-        async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
-          if (!this.portal.roomKey) {
+        switch (decryptedData.type) {
+          case "INVALID_RESPONSE":
             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,
+          case SCENE.INIT: {
+            if (!this.portal.socketInitialized) {
+              this.initializeSocket();
+              const remoteElements = decryptedData.payload.elements;
+              const reconciledElements = this.reconcileElements(remoteElements);
+              this.handleRemoteSceneUpdate(reconciledElements, {
+                init: true,
               });
-              break;
+              // 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({
-        isCollaborating: true,
-        activeRoomLink: window.location.href,
-      });
+    this.portal.socket!.on("first-in-room", () => {
+      if (this.portal.socket) {
+        this.portal.socket.off("first-in-room");
+      }
+      this.initializeSocket();
+      scenePromise.resolve(null);
+    });
 
-      return scenePromise;
-    }
+    this.setState({
+      activeRoomLink: window.location.href,
+    });
 
-    return null;
+    return scenePromise;
   };
 
   private initializeSocket = () => {
@@ -480,9 +519,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
 
   /** 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;

+ 1 - 1
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),

+ 9 - 1
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<readonly ExcalidrawElement[] | null> => {
   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);
 };

+ 18 - 8
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 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 generateCollaborationLink = async () => {
-  const id = await generateRandomID();
-  const key = await generateEncryptionKey();
-  return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
+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) =>

+ 22 - 98
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<typeof loadScene>,
-): 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<Scene | null> => {
+  collabAPI: CollabAPI;
+}): Promise<ImportedDataState | null> => {
   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}

+ 1 - 0
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?",