diff --git a/apps/client/src/features/editor/components/excalidraw/collab.tsx b/apps/client/src/features/editor/components/excalidraw/collab.tsx new file mode 100644 index 000000000..2861ab37d --- /dev/null +++ b/apps/client/src/features/editor/components/excalidraw/collab.tsx @@ -0,0 +1,1049 @@ +import { + CaptureUpdateAction, + getSceneVersion, + restoreElements, + zoomToFitBounds, + reconcileElements, +} from "@excalidraw/excalidraw"; +import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; +import { APP_NAME, cloneJSON, EVENT, toBrandedType } from "@excalidraw/common"; +import { + IDLE_THRESHOLD, + ACTIVE_THRESHOLD, + UserIdleState, + assertNever, + isDevEnv, + isTestEnv, + preventUnload, + resolvablePromise, + throttleRAF, +} from "@excalidraw/common"; +import { decryptData } from "@excalidraw/excalidraw/data/encryption"; +import { getVisibleSceneBounds } from "@excalidraw/element"; +import { newElementWith } from "@excalidraw/element"; +import { isImageElement, isInitializedImageElement } from "@excalidraw/element"; +import { AbortError } from "@excalidraw/excalidraw/errors"; +import { t } from "@excalidraw/excalidraw/i18n"; +import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils"; + +import throttle from "lodash.throttle"; +import { PureComponent } from "react"; + +import { bumpElementVersions } from "@excalidraw/excalidraw/data/restore"; + +import type { + ReconciledExcalidrawElement, + RemoteExcalidrawElement, +} from "@excalidraw/excalidraw/data/reconcile"; +import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; +import type { + ExcalidrawElement, + FileId, + InitializedExcalidrawImageElement, + OrderedExcalidrawElement, +} from "@excalidraw/element/types"; +import type { + BinaryFileData, + ExcalidrawImperativeAPI, + SocketId, + Collaborator, + Gesture, +} from "@excalidraw/excalidraw/types"; +import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; + +import { appJotaiStore, atom } from "../app-jotai"; +import { + CURSOR_SYNC_TIMEOUT, + FILE_UPLOAD_MAX_BYTES, + FIREBASE_STORAGE_PREFIXES, + INITIAL_SCENE_UPDATE_TIMEOUT, + LOAD_IMAGES_TIMEOUT, + WS_SUBTYPES, + SYNC_FULL_SCENE_INTERVAL_MS, + WS_EVENTS, +} from "../app_constants"; +import { + generateCollaborationLinkData, + getCollaborationLink, + getSyncableElements, +} from "../data"; +import { + encodeFilesForUpload, + FileManager, + updateStaleImageStatuses, +} from "../data/FileManager"; +import { LocalData } from "../data/LocalData"; +import { + isSavedToFirebase, + loadFilesFromFirebase, + loadFromFirebase, + saveFilesToFirebase, + saveToFirebase, +} from "../data/firebase"; +import { + importUsernameFromLocalStorage, + saveUsernameToLocalStorage, +} from "../data/localStorage"; +import { resetBrowserStateVersions } from "../data/tabSync"; + +import { collabErrorIndicatorAtom } from "./CollabError"; +import Portal from "./Portal"; + +import type { + SocketUpdateDataSource, + SyncableExcalidrawElement, +} from "../data"; + +export const collabAPIAtom = atom(null); +export const isCollaboratingAtom = atom(false); +export const isOfflineAtom = atom(false); + +interface CollabState { + errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; + username: string; + activeRoomLink: string | null; +} + +export const activeRoomLinkAtom = atom(null); + +type CollabInstance = InstanceType; + +export interface CollabAPI { + /** function so that we can access the latest value from stale callbacks */ + isCollaborating: () => boolean; + onPointerUpdate: CollabInstance["onPointerUpdate"]; + startCollaboration: CollabInstance["startCollaboration"]; + stopCollaboration: CollabInstance["stopCollaboration"]; + syncElements: CollabInstance["syncElements"]; + fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"]; + setUsername: CollabInstance["setUsername"]; + getUsername: CollabInstance["getUsername"]; + getActiveRoomLink: CollabInstance["getActiveRoomLink"]; + setCollabError: CollabInstance["setErrorDialog"]; +} + +interface CollabProps { + excalidrawAPI: ExcalidrawImperativeAPI; +} + +class Collab extends PureComponent { + portal: Portal; + fileManager: FileManager; + excalidrawAPI: CollabProps["excalidrawAPI"]; + activeIntervalId: number | null; + idleTimeoutId: number | null; + + private socketInitializationTimer?: number; + private lastBroadcastedOrReceivedSceneVersion: number = -1; + private collaborators = new Map(); + + constructor(props: CollabProps) { + super(props); + this.state = { + errorMessage: null, + dialogNotifiedErrors: {}, + username: importUsernameFromLocalStorage() || "", + activeRoomLink: null, + }; + this.portal = new Portal(this); + this.fileManager = new FileManager({ + getFiles: async (fileIds) => { + const { roomId, roomKey } = this.portal; + if (!roomId || !roomKey) { + throw new AbortError(); + } + + return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds); + }, + saveFiles: async ({ addedFiles }) => { + const { roomId, roomKey } = this.portal; + if (!roomId || !roomKey) { + throw new AbortError(); + } + + const { savedFiles, erroredFiles } = await saveFilesToFirebase({ + prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`, + files: await encodeFilesForUpload({ + files: addedFiles, + encryptionKey: roomKey, + maxBytes: FILE_UPLOAD_MAX_BYTES, + }), + }); + + return { + savedFiles: savedFiles.reduce( + (acc: Map, id) => { + const fileData = addedFiles.get(id); + if (fileData) { + acc.set(id, fileData); + } + return acc; + }, + new Map(), + ), + erroredFiles: erroredFiles.reduce( + (acc: Map, id) => { + const fileData = addedFiles.get(id); + if (fileData) { + acc.set(id, fileData); + } + return acc; + }, + new Map(), + ), + }; + }, + }); + this.excalidrawAPI = props.excalidrawAPI; + this.activeIntervalId = null; + this.idleTimeoutId = null; + } + + private onUmmount: (() => void) | null = null; + + componentDidMount() { + window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); + window.addEventListener("online", this.onOfflineStatusToggle); + window.addEventListener("offline", this.onOfflineStatusToggle); + window.addEventListener(EVENT.UNLOAD, this.onUnload); + + const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => { + this.portal.socket && this.portal.broadcastUserFollowed(payload); + }); + const throttledRelayUserViewportBounds = throttleRAF( + this.relayVisibleSceneBounds, + ); + const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() => + throttledRelayUserViewportBounds(), + ); + this.onUmmount = () => { + unsubOnUserFollow(); + unsubOnScrollChange(); + }; + + this.onOfflineStatusToggle(); + + const collabAPI: CollabAPI = { + isCollaborating: this.isCollaborating, + onPointerUpdate: this.onPointerUpdate, + startCollaboration: this.startCollaboration, + syncElements: this.syncElements, + fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase, + stopCollaboration: this.stopCollaboration, + setUsername: this.setUsername, + getUsername: this.getUsername, + getActiveRoomLink: this.getActiveRoomLink, + setCollabError: this.setErrorDialog, + }; + + appJotaiStore.set(collabAPIAtom, collabAPI); + + if (isTestEnv() || isDevEnv()) { + window.collab = window.collab || ({} as Window["collab"]); + Object.defineProperties(window, { + collab: { + configurable: true, + value: this, + }, + }); + } + } + + onOfflineStatusToggle = () => { + appJotaiStore.set(isOfflineAtom, !window.navigator.onLine); + }; + + componentWillUnmount() { + window.removeEventListener("online", this.onOfflineStatusToggle); + window.removeEventListener("offline", this.onOfflineStatusToggle); + window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); + window.removeEventListener(EVENT.UNLOAD, this.onUnload); + window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove); + window.removeEventListener( + EVENT.VISIBILITY_CHANGE, + this.onVisibilityChange, + ); + if (this.activeIntervalId) { + window.clearInterval(this.activeIntervalId); + this.activeIntervalId = null; + } + if (this.idleTimeoutId) { + window.clearTimeout(this.idleTimeoutId); + this.idleTimeoutId = null; + } + this.onUmmount?.(); + } + + isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!; + + private setIsCollaborating = (isCollaborating: boolean) => { + appJotaiStore.set(isCollaboratingAtom, isCollaborating); + }; + + private onUnload = () => { + this.destroySocketClient({ isUnload: true }); + }; + + private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { + const syncableElements = getSyncableElements( + this.getSceneElementsIncludingDeleted(), + ); + + if ( + this.isCollaborating() && + (this.fileManager.shouldPreventUnload(syncableElements) || + !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); + + if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") { + preventUnload(event); + } else { + console.warn( + "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)", + ); + } + } + }); + + saveCollabRoomToFirebase = async ( + syncableElements: readonly SyncableExcalidrawElement[], + ) => { + syncableElements = cloneJSON(syncableElements); + try { + const storedElements = await saveToFirebase( + this.portal, + syncableElements, + this.excalidrawAPI.getAppState(), + ); + + this.resetErrorIndicator(); + + if (this.isCollaborating() && storedElements) { + this.handleRemoteSceneUpdate(this._reconcileElements(storedElements)); + } + } catch (error: any) { + const errorMessage = /is longer than.*?bytes/.test(error.message) + ? t("errors.collabSaveFailed_sizeExceeded") + : t("errors.collabSaveFailed"); + + if ( + !this.state.dialogNotifiedErrors[errorMessage] || + !this.isCollaborating() + ) { + this.setErrorDialog(errorMessage); + this.setState({ + dialogNotifiedErrors: { + ...this.state.dialogNotifiedErrors, + [errorMessage]: true, + }, + }); + } + + if (this.isCollaborating()) { + this.setErrorIndicator(errorMessage); + } + + console.error(error); + } + }; + + stopCollaboration = (keepRemoteState = true) => { + this.queueBroadcastAllElements.cancel(); + this.queueSaveToFirebase.cancel(); + this.loadImageFiles.cancel(); + this.resetErrorIndicator(true); + + this.saveCollabRoomToFirebase( + getSyncableElements( + this.excalidrawAPI.getSceneElementsIncludingDeleted(), + ), + ); + + if (this.portal.socket && this.fallbackInitializationHandler) { + this.portal.socket.off( + "connect_error", + this.fallbackInitializationHandler, + ); + } + + if (!keepRemoteState) { + LocalData.fileStorage.reset(); + this.destroySocketClient(); + } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) { + // hack to ensure that we prefer we disregard any new browser state + // that could have been saved in other tabs while we were collaborating + resetBrowserStateVersions(); + + window.history.pushState({}, APP_NAME, window.location.origin); + this.destroySocketClient(); + + LocalData.fileStorage.reset(); + + const elements = this.excalidrawAPI + .getSceneElementsIncludingDeleted() + .map((element) => { + if (isImageElement(element) && element.status === "saved") { + return newElementWith(element, { status: "pending" }); + } + return element; + }); + + this.excalidrawAPI.updateScene({ + elements, + captureUpdate: CaptureUpdateAction.NEVER, + }); + } + }; + + private destroySocketClient = (opts?: { isUnload: boolean }) => { + this.lastBroadcastedOrReceivedSceneVersion = -1; + this.portal.close(); + this.fileManager.reset(); + if (!opts?.isUnload) { + this.setIsCollaborating(false); + this.setActiveRoomLink(null); + this.collaborators = new Map(); + this.excalidrawAPI.updateScene({ + collaborators: this.collaborators, + }); + LocalData.resumeSave("collaboration"); + } + }; + + private fetchImageFilesFromFirebase = async (opts: { + elements: readonly ExcalidrawElement[]; + /** + * Indicates whether to fetch files that are errored or pending and older + * than 10 seconds. + * + * Use this as a mechanism to fetch files which may be ok but for some + * reason their status was not updated correctly. + */ + forceFetchFiles?: boolean; + }) => { + const unfetchedImages = opts.elements + .filter((element) => { + return ( + isInitializedImageElement(element) && + !this.fileManager.isFileTracked(element.fileId) && + !element.isDeleted && + (opts.forceFetchFiles + ? element.status !== "pending" || + Date.now() - element.updated > 10000 + : element.status === "saved") + ); + }) + .map((element) => (element as InitializedExcalidrawImageElement).fileId); + + return await this.fileManager.getFiles(unfetchedImages); + }; + + private decryptPayload = async ( + iv: Uint8Array, + encryptedData: ArrayBuffer, + decryptionKey: string, + ): Promise> => { + try { + const decrypted = await decryptData(iv, encryptedData, decryptionKey); + + const decodedData = new TextDecoder("utf-8").decode( + new Uint8Array(decrypted), + ); + return JSON.parse(decodedData); + } catch (error) { + window.alert(t("alerts.decryptFailed")); + console.error(error); + return { + type: WS_SUBTYPES.INVALID_RESPONSE, + }; + } + }; + + private fallbackInitializationHandler: null | (() => any) = null; + + startCollaboration = async ( + existingRoomLinkData: null | { roomId: string; roomKey: string }, + ) => { + if (!this.state.username) { + import("@excalidraw/random-username").then(({ getRandomUsername }) => { + const username = getRandomUsername(); + this.setUsername(username); + }); + } + + if (this.portal.socket) { + return null; + } + + let roomId; + let roomKey; + + if (existingRoomLinkData) { + ({ roomId, roomKey } = existingRoomLinkData); + } else { + ({ roomId, roomKey } = await generateCollaborationLinkData()); + window.history.pushState( + {}, + APP_NAME, + getCollaborationLink({ roomId, roomKey }), + ); + } + + // TODO: `ImportedDataState` type here seems abused + const scenePromise = resolvablePromise< + | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] }) + | null + >(); + + this.setIsCollaborating(true); + LocalData.pauseSave("collaboration"); + + const { default: socketIOClient } = await import( + /* webpackChunkName: "socketIoClient" */ "socket.io-client" + ); + + const fallbackInitializationHandler = () => { + this.initializeRoom({ + roomLinkData: existingRoomLinkData, + fetchScene: true, + }).then((scene) => { + scenePromise.resolve(scene); + }); + }; + this.fallbackInitializationHandler = fallbackInitializationHandler; + + try { + this.portal.socket = this.portal.open( + socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, { + transports: ["websocket", "polling"], + }), + roomId, + roomKey, + ); + + this.portal.socket.once("connect_error", fallbackInitializationHandler); + } catch (error: any) { + console.error(error); + this.setErrorDialog(error.message); + return null; + } + + if (existingRoomLinkData) { + // when joining existing room, don't merge it with current scene data + this.excalidrawAPI.resetScene(); + } else { + const elements = this.excalidrawAPI.getSceneElements().map((element) => { + if (isImageElement(element) && element.status === "saved") { + return newElementWith(element, { status: "pending" }); + } + return element; + }); + // remove deleted elements from elements array 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.updateScene({ + elements, + captureUpdate: CaptureUpdateAction.NEVER, + }); + + this.saveCollabRoomToFirebase(getSyncableElements(elements)); + } + + // fallback in case you're not alone in the room but still don't receive + // initial SCENE_INIT message + this.socketInitializationTimer = window.setTimeout( + fallbackInitializationHandler, + 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 this.decryptPayload( + iv, + encryptedData, + this.portal.roomKey, + ); + + switch (decryptedData.type) { + case WS_SUBTYPES.INVALID_RESPONSE: + return; + case WS_SUBTYPES.INIT: { + if (!this.portal.socketInitialized) { + this.initializeRoom({ fetchScene: false }); + const remoteElements = toBrandedType< + readonly RemoteExcalidrawElement[] + >(decryptedData.payload.elements); + const reconciledElements = + this._reconcileElements(remoteElements); + this.handleRemoteSceneUpdate(reconciledElements); + // noop if already resolved via init from firebase + scenePromise.resolve({ + elements: reconciledElements, + scrollToContent: true, + }); + } + break; + } + case WS_SUBTYPES.UPDATE: + this.handleRemoteSceneUpdate( + this._reconcileElements( + toBrandedType( + decryptedData.payload.elements, + ), + ), + ); + break; + case WS_SUBTYPES.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; + + this.updateCollaborator(socketId, { + pointer, + button, + selectedElementIds, + username, + }); + + break; + } + + case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: { + const { sceneBounds, socketId } = decryptedData.payload; + + const appState = this.excalidrawAPI.getAppState(); + + // we're not following the user + // (shouldn't happen, but could be late message or bug upstream) + if (appState.userToFollow?.socketId !== socketId) { + console.warn( + `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`, + ); + return; + } + + // cross-follow case, ignore updates in this case + if ( + appState.userToFollow && + appState.followedBy.has(appState.userToFollow.socketId) + ) { + return; + } + + this.excalidrawAPI.updateScene({ + appState: zoomToFitBounds({ + appState, + bounds: sceneBounds, + fitToViewport: true, + viewportZoomFactor: 1, + }).appState, + }); + + break; + } + + case WS_SUBTYPES.IDLE_STATUS: { + const { userState, socketId, username } = decryptedData.payload; + this.updateCollaborator(socketId, { + userState, + username, + }); + break; + } + + default: { + assertNever(decryptedData, null); + } + } + }, + ); + + this.portal.socket.on("first-in-room", async () => { + if (this.portal.socket) { + this.portal.socket.off("first-in-room"); + } + const sceneData = await this.initializeRoom({ + fetchScene: true, + roomLinkData: existingRoomLinkData, + }); + scenePromise.resolve(sceneData); + }); + + this.portal.socket.on( + WS_EVENTS.USER_FOLLOW_ROOM_CHANGE, + (followedBy: SocketId[]) => { + this.excalidrawAPI.updateScene({ + appState: { followedBy: new Set(followedBy) }, + }); + + this.relayVisibleSceneBounds({ force: true }); + }, + ); + + this.initializeIdleDetector(); + + this.setActiveRoomLink(window.location.href); + + return scenePromise; + }; + + private initializeRoom = async ({ + fetchScene, + roomLinkData, + }: + | { + fetchScene: true; + roomLinkData: { roomId: string; roomKey: string } | null; + } + | { fetchScene: false; roomLinkData?: null }) => { + clearTimeout(this.socketInitializationTimer!); + if (this.portal.socket && this.fallbackInitializationHandler) { + this.portal.socket.off( + "connect_error", + this.fallbackInitializationHandler, + ); + } + if (fetchScene && roomLinkData && this.portal.socket) { + this.excalidrawAPI.resetScene(); + + try { + const elements = await loadFromFirebase( + roomLinkData.roomId, + roomLinkData.roomKey, + this.portal.socket, + ); + if (elements) { + this.setLastBroadcastedOrReceivedSceneVersion( + getSceneVersion(elements), + ); + + return { + elements, + scrollToContent: true, + }; + } + } catch (error: any) { + // log the error and move on. other peers will sync us the scene. + console.error(error); + } finally { + this.portal.socketInitialized = true; + } + } else { + this.portal.socketInitialized = true; + } + return null; + }; + + private _reconcileElements = ( + remoteElements: readonly RemoteExcalidrawElement[], + ): ReconciledExcalidrawElement[] => { + const appState = this.excalidrawAPI.getAppState(); + + const existingElements = this.getSceneElementsIncludingDeleted(); + + // NOTE ideally we restore _after_ reconciliation but we can't do that + // as we'd regenerate even elements such as appState.newElement which would + // break the state + remoteElements = restoreElements(remoteElements, existingElements); + + let reconciledElements = reconcileElements( + existingElements, + remoteElements, + appState, + ); + + reconciledElements = bumpElementVersions( + reconciledElements, + existingElements, + ); + + // Avoid broadcasting to the rest of the collaborators the scene + // we just received! + // Note: this needs to be set before updating the scene as it + // synchronously calls render. + this.setLastBroadcastedOrReceivedSceneVersion( + getSceneVersion(reconciledElements), + ); + + return reconciledElements; + }; + + private loadImageFiles = throttle(async () => { + const { loadedFiles, erroredFiles } = + await this.fetchImageFilesFromFirebase({ + elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(), + }); + + this.excalidrawAPI.addFiles(loadedFiles); + + updateStaleImageStatuses({ + excalidrawAPI: this.excalidrawAPI, + erroredFiles, + elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(), + }); + }, LOAD_IMAGES_TIMEOUT); + + private handleRemoteSceneUpdate = ( + elements: ReconciledExcalidrawElement[], + ) => { + this.excalidrawAPI.updateScene({ + elements, + captureUpdate: CaptureUpdateAction.NEVER, + }); + + this.loadImageFiles(); + }; + + private onPointerMove = () => { + if (this.idleTimeoutId) { + window.clearTimeout(this.idleTimeoutId); + this.idleTimeoutId = null; + } + + this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD); + + if (!this.activeIntervalId) { + this.activeIntervalId = window.setInterval( + this.reportActive, + ACTIVE_THRESHOLD, + ); + } + }; + + private onVisibilityChange = () => { + if (document.hidden) { + if (this.idleTimeoutId) { + window.clearTimeout(this.idleTimeoutId); + this.idleTimeoutId = null; + } + if (this.activeIntervalId) { + window.clearInterval(this.activeIntervalId); + this.activeIntervalId = null; + } + this.onIdleStateChange(UserIdleState.AWAY); + } else { + this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD); + this.activeIntervalId = window.setInterval( + this.reportActive, + ACTIVE_THRESHOLD, + ); + this.onIdleStateChange(UserIdleState.ACTIVE); + } + }; + + private reportIdle = () => { + this.onIdleStateChange(UserIdleState.IDLE); + if (this.activeIntervalId) { + window.clearInterval(this.activeIntervalId); + this.activeIntervalId = null; + } + }; + + private reportActive = () => { + this.onIdleStateChange(UserIdleState.ACTIVE); + }; + + private initializeIdleDetector = () => { + document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove); + document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange); + }; + + setCollaborators(sockets: SocketId[]) { + const collaborators: InstanceType["collaborators"] = + new Map(); + for (const socketId of sockets) { + collaborators.set( + socketId, + Object.assign({}, this.collaborators.get(socketId), { + isCurrentUser: socketId === this.portal.socket?.id, + }), + ); + } + this.collaborators = collaborators; + this.excalidrawAPI.updateScene({ collaborators }); + } + + updateCollaborator = (socketId: SocketId, updates: Partial) => { + const collaborators = new Map(this.collaborators); + const user: Mutable = Object.assign( + {}, + collaborators.get(socketId), + updates, + { + isCurrentUser: socketId === this.portal.socket?.id, + }, + ); + collaborators.set(socketId, user); + this.collaborators = collaborators; + + this.excalidrawAPI.updateScene({ + collaborators, + }); + }; + + public setLastBroadcastedOrReceivedSceneVersion = (version: number) => { + this.lastBroadcastedOrReceivedSceneVersion = version; + }; + + public getLastBroadcastedOrReceivedSceneVersion = () => { + return this.lastBroadcastedOrReceivedSceneVersion; + }; + + public getSceneElementsIncludingDeleted = () => { + return this.excalidrawAPI.getSceneElementsIncludingDeleted(); + }; + + onPointerUpdate = throttle( + (payload: { + pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; + button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; + pointersMap: Gesture["pointers"]; + }) => { + payload.pointersMap.size < 2 && + this.portal.socket && + this.portal.broadcastMouseLocation(payload); + }, + CURSOR_SYNC_TIMEOUT, + ); + + relayVisibleSceneBounds = (props?: { force: boolean }) => { + const appState = this.excalidrawAPI.getAppState(); + + if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) { + this.portal.broadcastVisibleSceneBounds( + { + sceneBounds: getVisibleSceneBounds(appState), + }, + `follow@${this.portal.socket.id}`, + ); + } + }; + + onIdleStateChange = (userState: UserIdleState) => { + this.portal.broadcastIdleChange(userState); + }; + + broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => { + if ( + getSceneVersion(elements) > + this.getLastBroadcastedOrReceivedSceneVersion() + ) { + this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false); + this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements); + this.queueBroadcastAllElements(); + } + }; + + syncElements = (elements: readonly OrderedExcalidrawElement[]) => { + this.broadcastElements(elements); + this.queueSaveToFirebase(); + }; + + queueBroadcastAllElements = throttle(() => { + this.portal.broadcastScene( + WS_SUBTYPES.UPDATE, + this.excalidrawAPI.getSceneElementsIncludingDeleted(), + true, + ); + const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion(); + const newVersion = Math.max( + currentVersion, + getSceneVersion(this.getSceneElementsIncludingDeleted()), + ); + this.setLastBroadcastedOrReceivedSceneVersion(newVersion); + }, SYNC_FULL_SCENE_INTERVAL_MS); + + queueSaveToFirebase = throttle( + () => { + if (this.portal.socketInitialized) { + this.saveCollabRoomToFirebase( + getSyncableElements( + this.excalidrawAPI.getSceneElementsIncludingDeleted(), + ), + ); + } + }, + SYNC_FULL_SCENE_INTERVAL_MS, + { leading: false }, + ); + + setUsername = (username: string) => { + this.setState({ username }); + saveUsernameToLocalStorage(username); + }; + + getUsername = () => this.state.username; + + setActiveRoomLink = (activeRoomLink: string | null) => { + this.setState({ activeRoomLink }); + appJotaiStore.set(activeRoomLinkAtom, activeRoomLink); + }; + + getActiveRoomLink = () => this.state.activeRoomLink; + + setErrorIndicator = (errorMessage: string | null) => { + appJotaiStore.set(collabErrorIndicatorAtom, { + message: errorMessage, + nonce: Date.now(), + }); + }; + + resetErrorIndicator = (resetDialogNotifiedErrors = false) => { + appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 }); + if (resetDialogNotifiedErrors) { + this.setState({ + dialogNotifiedErrors: {}, + }); + } + }; + + setErrorDialog = (errorMessage: string | null) => { + this.setState({ + errorMessage, + }); + }; + + render() { + const { errorMessage } = this.state; + + return ( + <> + {errorMessage != null && ( + this.setErrorDialog(null)}> + {errorMessage} + + )} + + ); + } +} + +declare global { + interface Window { + collab: InstanceType; + } +} + +if (isTestEnv() || isDevEnv()) { + window.collab = window.collab || ({} as Window["collab"]); +} + +export default Collab; + +export type TCollabClass = Collab; diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts b/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts index 0fc9898f0..2bf21ddac 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-utils.ts @@ -1,3 +1,5 @@ +import { ENCRYPTION_KEY_BITS } from "@excalidraw/common"; + type LibraryItems = any; type LibraryPersistedData = { @@ -8,8 +10,8 @@ export interface LibraryPersistenceAdapter { load(metadata: { source: "load" | "save" }): | Promise<{ libraryItems: LibraryItems } | null> | { - libraryItems: LibraryItems; - } + libraryItems: LibraryItems; + } | null; save(libraryData: LibraryPersistedData): Promise | void; @@ -25,7 +27,10 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = { return JSON.parse(data); } } catch (e) { - console.error("Error downloading Excalidraw library from localStorage", e); + console.error( + "Error downloading Excalidraw library from localStorage", + e, + ); } return null; }, @@ -40,3 +45,124 @@ export const localStorageLibraryAdapter: LibraryPersistenceAdapter = { } }, }; + +export const blobToArrayBuffer = (blob: Blob): Promise => { + if ("arrayBuffer" in blob) { + return blob.arrayBuffer(); + } + // Safari + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + if (!event.target?.result) { + return reject(new Error("Couldn't convert blob to ArrayBuffer")); + } + resolve(event.target.result as ArrayBuffer); + }; + reader.readAsArrayBuffer(blob); + }); +}; + +export const IV_LENGTH_BYTES = 12; + +// Pre-transform error: No known conditions for "./data/encryption" specifier in "@excalidraw/excalidraw" package +// Plugin: vite:import-analysis +// File: /Users/lite/WebstormProjects/docmost-ee/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts:11:7 +// 7 | decryptData, +// 8 | encryptData +// 9 | } from "@excalidraw/excalidraw/data/encryption"; + +//@ts-ignore +export const createIV = (): Uint8Array => { + const arr = new Uint8Array(IV_LENGTH_BYTES); + return window.crypto.getRandomValues(arr); +}; + +export const generateEncryptionKey = async < + T extends "string" | "cryptoKey" = "string", +>( + returnAs?: T, +): Promise => { + const key = await window.crypto.subtle.generateKey( + { + name: "AES-GCM", + length: ENCRYPTION_KEY_BITS, + }, + true, // extractable + ["encrypt", "decrypt"], + ); + return ( + returnAs === "cryptoKey" + ? key + : (await window.crypto.subtle.exportKey("jwk", key)).k + ) as T extends "cryptoKey" ? CryptoKey : string; +}; + +export const getCryptoKey = (key: string, usage: KeyUsage) => + window.crypto.subtle.importKey( + "jwk", + { + alg: "A128GCM", + ext: true, + k: key, + key_ops: ["encrypt", "decrypt"], + kty: "oct", + }, + { + name: "AES-GCM", + length: ENCRYPTION_KEY_BITS, + }, + false, // extractable + [usage], + ); + +export const encryptData = async ( + key: string | CryptoKey, + //@ts-ignore + data: Uint8Array | ArrayBuffer | Blob | File | string, + //@ts-ignore +): Promise<{ encryptedBuffer: ArrayBuffer; iv: Uint8Array }> => { + const importedKey = + typeof key === "string" ? await getCryptoKey(key, "encrypt") : key; + const iv = createIV(); + //@ts-ignore + const buffer: ArrayBuffer | Uint8Array = + typeof data === "string" + ? new TextEncoder().encode(data) + : data instanceof Uint8Array + ? data + : data instanceof Blob + ? await blobToArrayBuffer(data) + : data; + + // We use symmetric encryption. AES-GCM is the recommended algorithm and + // includes checks that the ciphertext has not been modified by an attacker. + const encryptedBuffer = await window.crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + importedKey, + buffer, + ); + + return { encryptedBuffer, iv }; +}; + +export const decryptData = async ( + //@ts-ignore + iv: Uint8Array, + //@ts-ignore + encrypted: Uint8Array | ArrayBuffer, + privateKey: string, +): Promise => { + const key = await getCryptoKey(privateKey, "decrypt"); + return window.crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + encrypted, + ); +}; diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index 779a826dd..ed35a2dd3 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -8,13 +8,14 @@ import { Text, useComputedColorScheme, } from "@mantine/core"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { uploadFile } from "@/features/page/services/page-service.ts"; import { svgStringToFile } from "@/lib"; import { useDisclosure } from "@mantine/hooks"; import { getFileUrl } from "@/lib/config.ts"; import "@excalidraw/excalidraw/index.css"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; import { IAttachment } from "@/features/attachments/types/attachment.types"; import ReactClearModal from "react-clear-modal"; import clsx from "clsx"; @@ -24,6 +25,7 @@ import { Suspense } from "react"; import { useTranslation } from "react-i18next"; import { useHandleLibrary } from "@excalidraw/excalidraw"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; +import { useExcalidrawCollab } from "./use-excalidraw-collab"; const Excalidraw = lazy(() => import("@excalidraw/excalidraw").then((module) => ({ @@ -46,6 +48,16 @@ export default function ExcalidrawView(props: NodeViewProps) { const [opened, { open, close }] = useDisclosure(false); const computedColorScheme = useComputedColorScheme(); + const pageId = editor.storage?.pageId; + const { broadcastScene } = useExcalidrawCollab(excalidrawAPI, pageId, opened); + + const handleChange = useCallback( + (elements: readonly ExcalidrawElement[]) => { + broadcastScene(elements); + }, + [broadcastScene], + ); + const handleOpen = async () => { if (!editor.isEditable) { return; @@ -157,6 +169,7 @@ export default function ExcalidrawView(props: NodeViewProps) { scrollToContent: true, }} theme={computedColorScheme} + onChange={handleChange} /> diff --git a/apps/client/src/features/editor/components/excalidraw/portal.tsx b/apps/client/src/features/editor/components/excalidraw/portal.tsx new file mode 100644 index 000000000..e177d81b4 --- /dev/null +++ b/apps/client/src/features/editor/components/excalidraw/portal.tsx @@ -0,0 +1,257 @@ +import { CaptureUpdateAction } from "@excalidraw/excalidraw"; +import { trackEvent } from "@excalidraw/excalidraw/analytics"; +import { encryptData } from "@excalidraw/excalidraw/data/encryption"; +import { newElementWith } from "@excalidraw/element"; +import throttle from "lodash.throttle"; + +import type { UserIdleState } from "@excalidraw/common"; +import type { OrderedExcalidrawElement } from "@excalidraw/element/types"; +import type { + OnUserFollowedPayload, + SocketId, +} from "@excalidraw/excalidraw/types"; + +import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants"; +import { isSyncableElement } from "../data"; + +import type { + SocketUpdateData, + SocketUpdateDataSource, + SyncableExcalidrawElement, +} from "../data"; +import type { TCollabClass } from "./Collab"; +import type { Socket } from "socket.io-client"; + +class Portal { + collab: TCollabClass; + socket: 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(collab: TCollabClass) { + this.collab = collab; + } + + open(socket: Socket, id: string, key: string) { + this.socket = socket; + this.roomId = id; + this.roomKey = key; + + // Initialize socket listeners + this.socket.on("init-room", () => { + if (this.socket) { + this.socket.emit("join-room", this.roomId); + trackEvent("share", "room joined"); + } + }); + this.socket.on("new-user", async (_socketId: string) => { + this.broadcastScene( + WS_SUBTYPES.INIT, + this.collab.getSceneElementsIncludingDeleted(), + /* syncAll */ true, + ); + }); + this.socket.on("room-user-change", (clients: SocketId[]) => { + 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() { + return !!( + this.socketInitialized && + this.socket && + this.roomId && + this.roomKey + ); + } + + async _broadcastSocketData( + data: SocketUpdateData, + volatile: boolean = false, + roomId?: string, + ) { + if (this.isOpen()) { + const json = JSON.stringify(data); + const encoded = new TextEncoder().encode(json); + const { encryptedBuffer, iv } = await encryptData(this.roomKey!, encoded); + + this.socket?.emit( + volatile ? WS_EVENTS.SERVER_VOLATILE : WS_EVENTS.SERVER, + roomId ?? this.roomId, + encryptedBuffer, + iv, + ); + } + } + + queueFileUpload = throttle(async () => { + try { + await this.collab.fileManager.saveFiles({ + elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(), + files: this.collab.excalidrawAPI.getFiles(), + }); + } catch (error: any) { + if (error.name !== "AbortError") { + this.collab.excalidrawAPI.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + } + } + + let isChanged = false; + const newElements = this.collab.excalidrawAPI + .getSceneElementsIncludingDeleted() + .map((element) => { + if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) { + isChanged = true; + // this will signal collaborators to pull image data from server + // (using mutation instead of newElementWith otherwise it'd break + // in-progress dragging) + return newElementWith(element, { status: "saved" }); + } + return element; + }); + + if (isChanged) { + this.collab.excalidrawAPI.updateScene({ + elements: newElements, + captureUpdate: CaptureUpdateAction.NEVER, + }); + } + }, FILE_UPLOAD_TIMEOUT); + + broadcastScene = async ( + updateType: WS_SUBTYPES.INIT | WS_SUBTYPES.UPDATE, + elements: readonly OrderedExcalidrawElement[], + syncAll: boolean, + ) => { + if (updateType === WS_SUBTYPES.INIT && !syncAll) { + throw new Error("syncAll must be true when sending SCENE.INIT"); + } + + // 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). + const syncableElements = elements.reduce((acc, element) => { + if ( + (syncAll || + !this.broadcastedElementVersions.has(element.id) || + element.version > this.broadcastedElementVersions.get(element.id)!) && + isSyncableElement(element) + ) { + acc.push(element); + } + return acc; + }, [] as SyncableExcalidrawElement[]); + + const data: SocketUpdateDataSource[typeof updateType] = { + type: updateType, + payload: { + elements: syncableElements, + }, + }; + + for (const syncableElement of syncableElements) { + this.broadcastedElementVersions.set( + syncableElement.id, + syncableElement.version, + ); + } + + this.queueFileUpload(); + + await this._broadcastSocketData(data as SocketUpdateData); + }; + + broadcastIdleChange = (userState: UserIdleState) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["IDLE_STATUS"] = { + type: WS_SUBTYPES.IDLE_STATUS, + payload: { + socketId: this.socket.id as SocketId, + userState, + username: this.collab.state.username, + }, + }; + return this._broadcastSocketData( + data as SocketUpdateData, + true, // volatile + ); + } + }; + + broadcastMouseLocation = (payload: { + pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; + button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; + }) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["MOUSE_LOCATION"] = { + type: WS_SUBTYPES.MOUSE_LOCATION, + payload: { + socketId: this.socket.id as SocketId, + pointer: payload.pointer, + button: payload.button || "up", + selectedElementIds: + this.collab.excalidrawAPI.getAppState().selectedElementIds, + username: this.collab.state.username, + }, + }; + + return this._broadcastSocketData( + data as SocketUpdateData, + true, // volatile + ); + } + }; + + broadcastVisibleSceneBounds = ( + payload: { + sceneBounds: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"]["payload"]["sceneBounds"]; + }, + roomId: string, + ) => { + if (this.socket?.id) { + const data: SocketUpdateDataSource["USER_VISIBLE_SCENE_BOUNDS"] = { + type: WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS, + payload: { + socketId: this.socket.id as SocketId, + username: this.collab.state.username, + sceneBounds: payload.sceneBounds, + }, + }; + + return this._broadcastSocketData( + data as SocketUpdateData, + true, // volatile + roomId, + ); + } + }; + + broadcastUserFollowed = (payload: OnUserFollowedPayload) => { + if (this.socket?.id) { + this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload); + } + }; +} + +export default Portal; diff --git a/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts b/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts new file mode 100644 index 000000000..7ed142cb7 --- /dev/null +++ b/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts @@ -0,0 +1,145 @@ +import { useEffect, useRef, useCallback, useMemo } from "react"; +import { useAtom } from "jotai"; +import { socketAtom } from "@/features/websocket/atoms/socket-atom"; +import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import type { ExcalidrawElement } from "@excalidraw/element/types"; +import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw"; +import throttle from "lodash.throttle"; + +type Collaborator = { + socketId: string; + isCurrentUser?: boolean; +}; + +export function useExcalidrawCollab( + excalidrawAPI: ExcalidrawImperativeAPI | null, + pageId: string | undefined, + isOpen: boolean, +) { + const [socket] = useAtom(socketAtom); + const lastBroadcastedVersion = useRef(-1); + const isInitialized = useRef(false); + + const roomId = pageId ? `excalidraw-${pageId}` : null; + + // Create stable throttled broadcast function + const broadcastScene = useMemo( + () => + throttle((elements: readonly ExcalidrawElement[]) => { + if (!socket || !roomId || !isInitialized.current) { + console.log("broadcastScene: not ready", { + socket: !!socket, + roomId, + isInitialized: isInitialized.current, + }); + return; + } + + // getSceneVersion sums all element versions - increases on ANY change + const sceneVersion = getSceneVersion(elements); + + if (sceneVersion <= lastBroadcastedVersion.current) { + return; + } + + const data = { + type: "SCENE_UPDATE", + payload: { elements }, + }; + + // Send as plain JSON for now (no encryption) + const json = JSON.stringify(data); + console.log("Broadcasting scene, version:", sceneVersion); + + socket.emit("server-broadcast", [roomId, json, null]); + lastBroadcastedVersion.current = sceneVersion; + }, 100), + [socket, roomId], + ); + + // Handle incoming broadcasts + const handleClientBroadcast = useCallback( + (jsonData: string, _iv: Uint8Array | null) => { + if (!excalidrawAPI) return; + + try { + const data = JSON.parse(jsonData); + + if (data.type === "SCENE_UPDATE" && data.payload?.elements) { + const remoteElements = data.payload.elements; + const localElements = + excalidrawAPI.getSceneElementsIncludingDeleted(); + + const reconciledElements = reconcileElements( + localElements, + remoteElements, + excalidrawAPI.getAppState(), + ); + + excalidrawAPI.updateScene({ + elements: reconciledElements, + }); + + // Update version to prevent echo + lastBroadcastedVersion.current = getSceneVersion(reconciledElements); + } + } catch (err) { + console.error("Failed to process broadcast:", err); + } + }, + [excalidrawAPI], + ); + + // Handle room user changes + const handleRoomUserChange = useCallback( + (socketIds: string[]) => { + if (!excalidrawAPI || !socket) return; + + const collaborators = new Map(); + for (const id of socketIds) { + collaborators.set(id, { + socketId: id, + isCurrentUser: id === socket.id, + }); + } + // @ts-ignore + excalidrawAPI.updateScene({ collaborators }); + }, + [excalidrawAPI, socket], + ); + + // Join/leave room based on modal state + useEffect(() => { + if (!socket || !roomId || !isOpen) return; + + console.log("Joining room:", roomId); + socket.emit("join-room", roomId); + isInitialized.current = true; + + // Set up listeners + socket.on("client-broadcast", handleClientBroadcast); + socket.on("room-user-change", handleRoomUserChange); + socket.on("first-in-room", () => { + console.log("First in excalidraw room"); + }); + socket.on("new-user", (socketId: string) => { + console.log("New user joined:", socketId); + if (excalidrawAPI) { + broadcastScene(excalidrawAPI.getSceneElements()); + } + }); + + return () => { + console.log("Leaving room:", roomId); + socket.emit("leave-room", roomId); + socket.off("client-broadcast", handleClientBroadcast); + socket.off("room-user-change", handleRoomUserChange); + socket.off("first-in-room"); + socket.off("new-user"); + isInitialized.current = false; + lastBroadcastedVersion.current = -1; + }; + }, [socket, roomId, isOpen, handleClientBroadcast, handleRoomUserChange, broadcastScene, excalidrawAPI]); + + return { broadcastScene }; +} diff --git a/apps/server/src/ws/ws.gateway.ts b/apps/server/src/ws/ws.gateway.ts index d08d8cee2..5456887a4 100644 --- a/apps/server/src/ws/ws.gateway.ts +++ b/apps/server/src/ws/ws.gateway.ts @@ -1,4 +1,5 @@ import { + ConnectedSocket, MessageBody, OnGatewayConnection, OnGatewayDisconnect, @@ -76,7 +77,7 @@ export class WsGateway @SubscribeMessage('join-room') async handleJoinRoom( - client: Socket, + @ConnectedSocket() client: Socket, @MessageBody() roomId: string, ): Promise { await this.excalidrawCollabService.handleJoinRoom( @@ -87,14 +88,17 @@ export class WsGateway } @SubscribeMessage('leave-room') - handleLeaveRoom(client: Socket, @MessageBody() roomName: string): void { + handleLeaveRoom( + @ConnectedSocket() client: Socket, + @MessageBody() roomName: string, + ): void { client.leave(roomName); } @SubscribeMessage('server-broadcast') handleServerBroadcast( - client: Socket, - [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array], + @ConnectedSocket() client: Socket, + @MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array], ): void { this.excalidrawCollabService.handleServerBroadcast( client, @@ -106,8 +110,8 @@ export class WsGateway @SubscribeMessage('server-volatile-broadcast') handleServerVolatileBroadcast( - client: Socket, - [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array], + @ConnectedSocket() client: Socket, + @MessageBody() [roomId, encryptedData, iv]: [string, ArrayBuffer, Uint8Array], ): void { this.excalidrawCollabService.handleServerVolatileBroadcast( client, @@ -119,7 +123,7 @@ export class WsGateway @SubscribeMessage('user-follow') async handleUserFollow( - client: Socket, + @ConnectedSocket() client: Socket, @MessageBody() payload: ExcalidrawFollowPayload, ): Promise { await this.excalidrawCollabService.handleUserFollow(