aboutsummaryrefslogtreecommitdiffstats
path: root/excalidraw-app/collab/Portal.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'excalidraw-app/collab/Portal.tsx')
-rw-r--r--excalidraw-app/collab/Portal.tsx256
1 files changed, 256 insertions, 0 deletions
diff --git a/excalidraw-app/collab/Portal.tsx b/excalidraw-app/collab/Portal.tsx
new file mode 100644
index 0000000..1788d39
--- /dev/null
+++ b/excalidraw-app/collab/Portal.tsx
@@ -0,0 +1,256 @@
+import type {
+ SocketUpdateData,
+ SocketUpdateDataSource,
+ SyncableExcalidrawElement,
+} from "../data";
+import { isSyncableElement } from "../data";
+
+import type { TCollabClass } from "./Collab";
+
+import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
+import { WS_EVENTS, FILE_UPLOAD_TIMEOUT, WS_SUBTYPES } from "../app_constants";
+import type {
+ OnUserFollowedPayload,
+ SocketId,
+} from "@excalidraw/excalidraw/types";
+import type { UserIdleState } from "@excalidraw/excalidraw/constants";
+import { trackEvent } from "@excalidraw/excalidraw/analytics";
+import throttle from "lodash.throttle";
+import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement";
+import { encryptData } from "@excalidraw/excalidraw/data/encryption";
+import type { Socket } from "socket.io-client";
+import { CaptureUpdateAction } from "@excalidraw/excalidraw";
+
+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<string, number> = 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;