diff --git a/src/excalidraw-app/app_constants.ts b/src/excalidraw-app/app_constants.ts index 179fe52e7..a06201fe4 100644 --- a/src/excalidraw-app/app_constants.ts +++ b/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", diff --git a/src/excalidraw-app/collab/Collab.tsx b/src/excalidraw-app/collab/Collab.tsx index ec0c2a348..6b03d4239 100644 --- a/src/excalidraw-app/collab/Collab.tsx +++ b/src/excalidraw-app/collab/Collab.tsx @@ -76,6 +76,7 @@ export const collabAPIAtom = atom(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 { 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 { } }; + 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(); diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx index 1d4db3c0c..00d170208 100644 --- a/src/excalidraw-app/collab/Portal.tsx +++ b/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() { diff --git a/src/excalidraw-app/index.tsx b/src/excalidraw-app/index.tsx index 860437f3c..dd1b383db 100644 --- a/src/excalidraw-app/index.tsx +++ b/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 | 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; + } } }; diff --git a/src/locales/en.json b/src/locales/en.json index 7092e9be8..188a2f2b2 100644 --- a/src/locales/en.json +++ b/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",