From d18a72c879047bf026352f0671e86f5de6a25e3e Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 6 Oct 2020 04:34:40 +0200 Subject: [PATCH] save room to firebase on unload or portal close (#2207) * save on unload or portal close * align naming --- src/components/App.tsx | 53 +++++++++++++++++++++++++++++------------- src/data/firebase.ts | 47 ++++++++++++++++++++++++++++++------- 2 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/components/App.tsx b/src/components/App.tsx index c4663cfe..1127aafa 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -176,7 +176,11 @@ import { import { MaybeTransformHandleType } from "../element/transformHandles"; import { renderSpreadsheet } from "../charts"; import { isValidLibrary } from "../data/json"; -import { loadFromFirebase, saveToFirebase } from "../data/firebase"; +import { + loadFromFirebase, + saveToFirebase, + isSavedToFirebase, +} from "../data/firebase"; /** * @param func handler taking at most single parameter (event). @@ -469,7 +473,7 @@ class App extends React.Component { return false; } - const roomId = roomMatch[1]; + const roomID = roomMatch[1]; let collabForceLoadFlag; try { @@ -488,7 +492,7 @@ class App extends React.Component { ); // if loading same room as the one previously unloaded within 15sec // force reload without prompting - if (previousRoom === roomId && Date.now() - timestamp < 15000) { + if (previousRoom === roomID && Date.now() - timestamp < 15000) { return true; } } catch {} @@ -784,7 +788,17 @@ class App extends React.Component { ); } catch {} } - if (this.state.isCollaborating && this.scene.getElements().length > 0) { + const syncableElements = getSyncableElements( + this.scene.getElementsIncludingDeleted(), + ); + if ( + this.state.isCollaborating && + !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); + event.preventDefault(); // NOTE: modern browsers no longer allow showing a custom message here event.returnValue = ""; @@ -1182,6 +1196,7 @@ class App extends React.Component { }; closePortal = () => { + this.saveCollabRoomToFirebase(); window.history.pushState({}, "Excalidraw", window.location.origin); this.destroySocketClient(); }; @@ -1227,8 +1242,8 @@ class App extends React.Component { } const roomMatch = getCollaborationLinkData(window.location.href); if (roomMatch) { - const roomId = roomMatch[1]; - const roomSecret = roomMatch[2]; + const roomID = roomMatch[1]; + const roomKey = roomMatch[2]; const initialize = () => { this.portal.socketInitialized = true; @@ -1358,7 +1373,7 @@ class App extends React.Component { /* webpackChunkName: "socketIoClient" */ "socket.io-client" ); - this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomSecret); + this.portal.open(socketIOClient(SOCKET_SERVER), roomID, roomKey); // All socket listeners are moving to Portal this.portal.socket!.on( @@ -1430,7 +1445,7 @@ class App extends React.Component { }); try { - const elements = await loadFromFirebase(roomId, roomSecret); + const elements = await loadFromFirebase(roomID, roomKey); if (elements) { updateScene( { type: "SCENE_UPDATE", payload: { elements } }, @@ -1484,6 +1499,18 @@ class App extends React.Component { } }; + saveCollabRoomToFirebase = async ( + syncableElements: ExcalidrawElement[] = getSyncableElements( + this.scene.getElementsIncludingDeleted(), + ), + ) => { + try { + await saveToFirebase(this.portal, syncableElements); + } catch (error) { + console.error(error); + } + }; + // maybe should move to Portal broadcastScene = async ( sceneType: SCENE.INIT | SCENE.UPDATE, @@ -1530,16 +1557,10 @@ class App extends React.Component { data as SocketUpdateData, ); - if (syncAll && this.portal.roomID && this.portal.roomKey) { + if (syncAll && this.state.isCollaborating) { await Promise.all([ broadcastPromise, - saveToFirebase( - this.portal.roomID, - this.portal.roomKey, - syncableElements, - ).catch((e) => { - console.error(e); - }), + this.saveCollabRoomToFirebase(syncableElements), ]); } else { await broadcastPromise; diff --git a/src/data/firebase.ts b/src/data/firebase.ts index a256b1ee..87a1036c 100644 --- a/src/data/firebase.ts +++ b/src/data/firebase.ts @@ -1,6 +1,7 @@ import { createIV, getImportedKey } from "./index"; import { ExcalidrawElement } from "../element/types"; import { getSceneVersion } from "../element"; +import Portal from "../components/Portal"; let firebasePromise: Promise | null = null; @@ -69,14 +70,40 @@ async function decryptElements( return JSON.parse(decodedData); } +const firebaseSceneVersionCache = new WeakMap(); + +export const isSavedToFirebase = ( + portal: Portal, + elements: readonly ExcalidrawElement[], +): boolean => { + if (portal.socket && portal.roomID && portal.roomKey) { + const sceneVersion = getSceneVersion(elements); + return firebaseSceneVersionCache.get(portal.socket) === sceneVersion; + } + // if no room exists, consider the room saved so that we don't unnecessarily + // prevent unload (there's nothing we could do at that point anyway) + return true; +}; + export async function saveToFirebase( - roomId: string, - roomSecret: string, + portal: Portal, elements: readonly ExcalidrawElement[], ) { + const { roomID, roomKey, socket } = portal; + if ( + // if no room exists, consider the room saved because there's nothing we can + // do at this point + !roomID || + !roomKey || + !socket || + isSavedToFirebase(portal, elements) + ) { + return true; + } + const firebase = await getFirebase(); const sceneVersion = getSceneVersion(elements); - const { ciphertext, iv } = await encryptElements(roomSecret, elements); + const { ciphertext, iv } = await encryptElements(roomKey, elements); const nextDocData = { sceneVersion, @@ -87,7 +114,7 @@ export async function saveToFirebase( } as FirebaseStoredScene; const db = firebase.firestore(); - const docRef = db.collection("scenes").doc(roomId); + const docRef = db.collection("scenes").doc(roomID); const didUpdate = await db.runTransaction(async (transaction) => { const doc = await transaction.get(docRef); if (!doc.exists) { @@ -104,17 +131,21 @@ export async function saveToFirebase( return true; }); + if (didUpdate) { + firebaseSceneVersionCache.set(socket, sceneVersion); + } + return didUpdate; } export async function loadFromFirebase( - roomId: string, - roomSecret: string, + roomID: string, + roomKey: string, ): Promise { const firebase = await getFirebase(); const db = firebase.firestore(); - const docRef = db.collection("scenes").doc(roomId); + const docRef = db.collection("scenes").doc(roomID); const doc = await docRef.get(); if (!doc.exists) { return null; @@ -122,6 +153,6 @@ export async function loadFromFirebase( const storedScene = doc.data() as FirebaseStoredScene; const ciphertext = storedScene.ciphertext.toUint8Array(); const iv = storedScene.iv.toUint8Array(); - const plaintext = await decryptElements(roomSecret, iv, ciphertext); + const plaintext = await decryptElements(roomKey, iv, ciphertext); return plaintext; }