import { encryptAESGEM, SocketUpdateDataSource } from "../data"; import { SocketUpdateData } from "../types"; import { BROADCAST, SCENE } from "../constants"; import App from "./App"; import { getElementMap, getSceneVersion, getSyncableElements, } from "../element"; import { ExcalidrawElement } from "../element/types"; class Portal { app: App; socket: SocketIOClient.Socket | null = null; socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized roomID: string | null = null; roomKey: string | null = null; broadcastedElementVersions: Map = new Map(); constructor(app: App) { this.app = app; } open(socket: SocketIOClient.Socket, id: string, key: string) { this.socket = socket; this.roomID = id; this.roomKey = key; // Initialize socket listeners (moving from App) this.socket.on("init-room", () => { if (this.socket) { this.socket.emit("join-room", this.roomID); } }); this.socket.on("new-user", async (_socketId: string) => { this.broadcastScene(SCENE.INIT, /* syncAll */ true); }); this.socket.on("room-user-change", (clients: string[]) => { this.app.setCollaborators(clients); }); } close() { if (!this.socket) { return; } this.socket.close(); this.socket = null; this.roomID = null; this.roomKey = null; this.socketInitialized = false; this.broadcastedElementVersions = new Map(); } isOpen() { return !!( this.socketInitialized && this.socket && this.roomID && this.roomKey ); } async _broadcastSocketData( data: SocketUpdateData, volatile: boolean = false, ) { if (this.isOpen()) { const json = JSON.stringify(data); const encoded = new TextEncoder().encode(json); const encrypted = await encryptAESGEM(encoded, this.roomKey!); this.socket!.emit( volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER, this.roomID, encrypted.data, encrypted.iv, ); } } broadcastScene = async ( sceneType: SCENE.INIT | SCENE.UPDATE, syncAll: boolean, ) => { if (sceneType === SCENE.INIT && !syncAll) { throw new Error("syncAll must be true when sending SCENE.INIT"); } let syncableElements = getSyncableElements( this.app.getSceneElementsIncludingDeleted(), ); if (!syncAll) { // sync out only the elements we think we need to to save bandwidth. // periodically we'll resync the whole thing to make sure no one diverges // due to a dropped message (server goes down etc). syncableElements = syncableElements.filter( (syncableElement) => !this.broadcastedElementVersions.has(syncableElement.id) || syncableElement.version > this.broadcastedElementVersions.get(syncableElement.id)!, ); } const data: SocketUpdateDataSource[typeof sceneType] = { type: sceneType, payload: { elements: syncableElements, }, }; const currentVersion = this.app.getLastBroadcastedOrReceivedSceneVersion(); const newVersion = Math.max( currentVersion, getSceneVersion(this.app.getSceneElementsIncludingDeleted()), ); this.app.setLastBroadcastedOrReceivedSceneVersion(newVersion); for (const syncableElement of syncableElements) { this.broadcastedElementVersions.set( syncableElement.id, syncableElement.version, ); } const broadcastPromise = this._broadcastSocketData( data as SocketUpdateData, ); if (syncAll && this.app.state.isCollaborating) { await Promise.all([ broadcastPromise, this.app.saveCollabRoomToFirebase(syncableElements), ]); } else { await broadcastPromise; } }; broadcastMouseLocation = (payload: { pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; }) => { if (this.socket?.id) { const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { type: "MOUSE_LOCATION", payload: { socketId: this.socket.id, pointer: payload.pointer, button: payload.button || "up", selectedElementIds: this.app.state.selectedElementIds, username: this.app.state.username, }, }; return this._broadcastSocketData( data as SocketUpdateData, true, // volatile ); } }; reconcileElements = (sceneElements: readonly ExcalidrawElement[]) => { const currentElements = this.app.getSceneElementsIncludingDeleted(); // create a map of ids so we don't have to iterate // over the array more than once. const localElementMap = getElementMap(currentElements); // Reconcile const newElements = sceneElements .reduce((elements, element) => { // if the remote element references one that's currently // edited on local, skip it (it'll be added in the next step) if ( element.id === this.app.state.editingElement?.id || element.id === this.app.state.resizingElement?.id || element.id === this.app.state.draggingElement?.id ) { return elements; } if ( localElementMap.hasOwnProperty(element.id) && localElementMap[element.id].version > element.version ) { elements.push(localElementMap[element.id]); delete localElementMap[element.id]; } else if ( localElementMap.hasOwnProperty(element.id) && localElementMap[element.id].version === element.version && localElementMap[element.id].versionNonce !== element.versionNonce ) { // resolve conflicting edits deterministically by taking the one with the lowest versionNonce if (localElementMap[element.id].versionNonce < element.versionNonce) { elements.push(localElementMap[element.id]); } else { // it should be highly unlikely that the two versionNonces are the same. if we are // really worried about this, we can replace the versionNonce with the socket id. elements.push(element); } delete localElementMap[element.id]; } else { elements.push(element); delete localElementMap[element.id]; } return elements; }, [] as Mutable) // add local elements that weren't deleted or on remote .concat(...Object.values(localElementMap)); return newElements; }; } export default Portal;