1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

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

This commit is contained in:
David Luzar 2021-02-03 19:14:26 +01:00 committed by GitHub
parent 02598c6163
commit ce507b0a0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 223 additions and 239 deletions

@ -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;
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
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<ImportedDataState | null>();
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<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;

@ -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),

@ -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);
};

@ -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) =>

@ -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}

@ -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?",