Browse Source

feat: pause collab when user switches tabs in the browser

Arnošt Pleskot 2 years ago
parent
commit
addf9d71fa

+ 2 - 0
src/excalidraw-app/app_constants.ts

@@ -12,6 +12,8 @@ export const FILE_UPLOAD_MAX_BYTES = 3 * 1024 * 1024; // 3 MiB
 // 1 year (https://stackoverflow.com/a/25201898/927631)
 export const FILE_CACHE_MAX_AGE_SEC = 31536000;
 
+export const PAUSE_COLLABORATION_TIMEOUT = 30000;
+
 export const WS_EVENTS = {
   SERVER_VOLATILE: "server-volatile-broadcast",
   SERVER: "server-broadcast",

+ 38 - 0
src/excalidraw-app/collab/Collab.tsx

@@ -76,6 +76,7 @@ export const collabAPIAtom = atom<CollabAPI | null>(null);
 export const collabDialogShownAtom = atom(false);
 export const isCollaboratingAtom = atom(false);
 export const isOfflineAtom = atom(false);
+export const isCollaborationPausedAtom = atom(false);
 
 interface CollabState {
   errorMessage: string;
@@ -91,9 +92,12 @@ export interface CollabAPI {
   onPointerUpdate: CollabInstance["onPointerUpdate"];
   startCollaboration: CollabInstance["startCollaboration"];
   stopCollaboration: CollabInstance["stopCollaboration"];
+  pauseCollaboration: CollabInstance["pauseCollaboration"];
+  resumeCollaboration: CollabInstance["resumeCollaboration"];
   syncElements: CollabInstance["syncElements"];
   fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
   setUsername: (username: string) => void;
+  isPaused: () => boolean;
 }
 
 interface PublicProps {
@@ -167,6 +171,9 @@ class Collab extends PureComponent<Props, CollabState> {
       fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
       stopCollaboration: this.stopCollaboration,
       setUsername: this.setUsername,
+      pauseCollaboration: this.pauseCollaboration,
+      resumeCollaboration: this.resumeCollaboration,
+      isPaused: this.isPaused,
     };
 
     appJotaiStore.set(collabAPIAtom, collabAPI);
@@ -310,6 +317,37 @@ class Collab extends PureComponent<Props, CollabState> {
     }
   };
 
+  pauseCollaboration = (callback?: () => void) => {
+    if (this.portal.socket) {
+      this.reportIdle();
+      this.portal.socket.disconnect();
+      this.portal.socketInitialized = false;
+      this.setIsCollaborationPaused(true);
+
+      if (callback) {
+        callback();
+      }
+    }
+  };
+
+  resumeCollaboration = (callback?: () => void) => {
+    if (this.portal.socket) {
+      this.reportActive();
+      this.portal.socket.connect();
+      this.setIsCollaborationPaused(false);
+
+      if (callback) {
+        callback();
+      }
+    }
+  };
+
+  isPaused = () => appJotaiStore.get(isCollaborationPausedAtom)!;
+
+  setIsCollaborationPaused = (isPaused: boolean) => {
+    appJotaiStore.set(isCollaborationPausedAtom, isPaused);
+  };
+
   private destroySocketClient = (opts?: { isUnload: boolean }) => {
     this.lastBroadcastedOrReceivedSceneVersion = -1;
     this.portal.close();

+ 23 - 15
src/excalidraw-app/collab/Portal.tsx

@@ -37,6 +37,29 @@ class Portal {
     this.roomId = id;
     this.roomKey = key;
 
+    this.initializeSocketListeners();
+
+    return socket;
+  }
+
+  close() {
+    if (!this.socket) {
+      return;
+    }
+    this.queueFileUpload.flush();
+    this.socket.close();
+    this.socket = null;
+    this.roomId = null;
+    this.roomKey = null;
+    this.socketInitialized = false;
+    this.broadcastedElementVersions = new Map();
+  }
+
+  initializeSocketListeners() {
+    if (!this.socket) {
+      return;
+    }
+
     // Initialize socket listeners
     this.socket.on("init-room", () => {
       if (this.socket) {
@@ -54,21 +77,6 @@ class Portal {
     this.socket.on("room-user-change", (clients: string[]) => {
       this.collab.setCollaborators(clients);
     });
-
-    return socket;
-  }
-
-  close() {
-    if (!this.socket) {
-      return;
-    }
-    this.queueFileUpload.flush();
-    this.socket.close();
-    this.socket = null;
-    this.roomId = null;
-    this.roomKey = null;
-    this.socketInitialized = false;
-    this.broadcastedElementVersions = new Map();
   }
 
   isOpen() {

+ 44 - 0
src/excalidraw-app/index.tsx

@@ -46,6 +46,7 @@ import {
 } from "../utils";
 import {
   FIREBASE_STORAGE_PREFIXES,
+  PAUSE_COLLABORATION_TIMEOUT,
   STORAGE_KEYS,
   SYNC_BROWSER_TABS_TIMEOUT,
 } from "./app_constants";
@@ -293,6 +294,10 @@ const ExcalidrawWrapper = () => {
     getInitialLibraryItems: getLibraryItemsFromStorage,
   });
 
+  const pauseCollaborationTimeoutRef = useRef<ReturnType<
+    typeof setTimeout
+  > | null>(null);
+
   useEffect(() => {
     if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
       return;
@@ -471,6 +476,45 @@ const ExcalidrawWrapper = () => {
         event.type === EVENT.FOCUS
       ) {
         syncData();
+
+        switch (true) {
+          // user switches to another tab
+          case document.hidden && collabAPI.isCollaborating():
+            if (!pauseCollaborationTimeoutRef.current) {
+              pauseCollaborationTimeoutRef.current = setTimeout(() => {
+                collabAPI.pauseCollaboration(() =>
+                  excalidrawAPI.updateScene({
+                    appState: { viewModeEnabled: true },
+                  }),
+                );
+              }, PAUSE_COLLABORATION_TIMEOUT);
+            }
+            break;
+
+          // user returns to the tab with Excalidraw
+          case !document.hidden && collabAPI.isPaused():
+            excalidrawAPI.setToast({
+              message: t("toast.reconnectRoomServer"),
+              duration: 100000,
+              closable: true,
+            });
+
+            collabAPI.resumeCollaboration(() => {
+              excalidrawAPI.updateScene({
+                appState: { viewModeEnabled: false },
+              });
+              excalidrawAPI.setToast(null);
+            });
+            break;
+
+          // user returns and timeout hasn't fired yet
+          case !document.hidden && Boolean(pauseCollaborationTimeoutRef):
+            if (pauseCollaborationTimeoutRef.current) {
+              clearTimeout(pauseCollaborationTimeoutRef.current);
+              pauseCollaborationTimeoutRef.current = null;
+            }
+            break;
+        }
       }
     };
 

+ 2 - 1
src/locales/en.json

@@ -411,7 +411,8 @@
     "fileSavedToFilename": "Saved to {filename}",
     "canvas": "canvas",
     "selection": "selection",
-    "pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor"
+    "pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
+    "reconnectRoomServer": "Reconnecting to server"
   },
   "colors": {
     "transparent": "Transparent",