Collab.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023
  1. import {
  2. CaptureUpdateAction,
  3. getSceneVersion,
  4. restoreElements,
  5. zoomToFitBounds,
  6. reconcileElements,
  7. } from "@excalidraw/excalidraw";
  8. import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
  9. import { APP_NAME, EVENT } from "@excalidraw/common";
  10. import {
  11. IDLE_THRESHOLD,
  12. ACTIVE_THRESHOLD,
  13. UserIdleState,
  14. assertNever,
  15. isDevEnv,
  16. isTestEnv,
  17. preventUnload,
  18. resolvablePromise,
  19. throttleRAF,
  20. } from "@excalidraw/common";
  21. import { decryptData } from "@excalidraw/excalidraw/data/encryption";
  22. import { getVisibleSceneBounds } from "@excalidraw/element/bounds";
  23. import { newElementWith } from "@excalidraw/element/mutateElement";
  24. import {
  25. isImageElement,
  26. isInitializedImageElement,
  27. } from "@excalidraw/element/typeChecks";
  28. import { AbortError } from "@excalidraw/excalidraw/errors";
  29. import { t } from "@excalidraw/excalidraw/i18n";
  30. import { withBatchedUpdates } from "@excalidraw/excalidraw/reactUtils";
  31. import throttle from "lodash.throttle";
  32. import { PureComponent } from "react";
  33. import type {
  34. ReconciledExcalidrawElement,
  35. RemoteExcalidrawElement,
  36. } from "@excalidraw/excalidraw/data/reconcile";
  37. import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
  38. import type {
  39. ExcalidrawElement,
  40. FileId,
  41. InitializedExcalidrawImageElement,
  42. OrderedExcalidrawElement,
  43. } from "@excalidraw/element/types";
  44. import type {
  45. BinaryFileData,
  46. ExcalidrawImperativeAPI,
  47. SocketId,
  48. Collaborator,
  49. Gesture,
  50. } from "@excalidraw/excalidraw/types";
  51. import type { Mutable, ValueOf } from "@excalidraw/common/utility-types";
  52. import { appJotaiStore, atom } from "../app-jotai";
  53. import {
  54. CURSOR_SYNC_TIMEOUT,
  55. FILE_UPLOAD_MAX_BYTES,
  56. FIREBASE_STORAGE_PREFIXES,
  57. INITIAL_SCENE_UPDATE_TIMEOUT,
  58. LOAD_IMAGES_TIMEOUT,
  59. WS_SUBTYPES,
  60. SYNC_FULL_SCENE_INTERVAL_MS,
  61. WS_EVENTS,
  62. } from "../app_constants";
  63. import {
  64. generateCollaborationLinkData,
  65. getCollaborationLink,
  66. getSyncableElements,
  67. } from "../data";
  68. import {
  69. encodeFilesForUpload,
  70. FileManager,
  71. updateStaleImageStatuses,
  72. } from "../data/FileManager";
  73. import { LocalData } from "../data/LocalData";
  74. import {
  75. isSavedToFirebase,
  76. loadFilesFromFirebase,
  77. loadFromFirebase,
  78. saveFilesToFirebase,
  79. saveToFirebase,
  80. } from "../data/firebase";
  81. import {
  82. importUsernameFromLocalStorage,
  83. saveUsernameToLocalStorage,
  84. } from "../data/localStorage";
  85. import { resetBrowserStateVersions } from "../data/tabSync";
  86. import { collabErrorIndicatorAtom } from "./CollabError";
  87. import Portal from "./Portal";
  88. import type {
  89. SocketUpdateDataSource,
  90. SyncableExcalidrawElement,
  91. } from "../data";
  92. export const collabAPIAtom = atom<CollabAPI | null>(null);
  93. export const isCollaboratingAtom = atom(false);
  94. export const isOfflineAtom = atom(false);
  95. interface CollabState {
  96. errorMessage: string | null;
  97. /** errors related to saving */
  98. dialogNotifiedErrors: Record<string, boolean>;
  99. username: string;
  100. activeRoomLink: string | null;
  101. }
  102. export const activeRoomLinkAtom = atom<string | null>(null);
  103. type CollabInstance = InstanceType<typeof Collab>;
  104. export interface CollabAPI {
  105. /** function so that we can access the latest value from stale callbacks */
  106. isCollaborating: () => boolean;
  107. onPointerUpdate: CollabInstance["onPointerUpdate"];
  108. startCollaboration: CollabInstance["startCollaboration"];
  109. stopCollaboration: CollabInstance["stopCollaboration"];
  110. syncElements: CollabInstance["syncElements"];
  111. fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
  112. setUsername: CollabInstance["setUsername"];
  113. getUsername: CollabInstance["getUsername"];
  114. getActiveRoomLink: CollabInstance["getActiveRoomLink"];
  115. setCollabError: CollabInstance["setErrorDialog"];
  116. }
  117. interface CollabProps {
  118. excalidrawAPI: ExcalidrawImperativeAPI;
  119. }
  120. class Collab extends PureComponent<CollabProps, CollabState> {
  121. portal: Portal;
  122. fileManager: FileManager;
  123. excalidrawAPI: CollabProps["excalidrawAPI"];
  124. activeIntervalId: number | null;
  125. idleTimeoutId: number | null;
  126. private socketInitializationTimer?: number;
  127. private lastBroadcastedOrReceivedSceneVersion: number = -1;
  128. private collaborators = new Map<SocketId, Collaborator>();
  129. constructor(props: CollabProps) {
  130. super(props);
  131. this.state = {
  132. errorMessage: null,
  133. dialogNotifiedErrors: {},
  134. username: importUsernameFromLocalStorage() || "",
  135. activeRoomLink: null,
  136. };
  137. this.portal = new Portal(this);
  138. this.fileManager = new FileManager({
  139. getFiles: async (fileIds) => {
  140. const { roomId, roomKey } = this.portal;
  141. if (!roomId || !roomKey) {
  142. throw new AbortError();
  143. }
  144. return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
  145. },
  146. saveFiles: async ({ addedFiles }) => {
  147. const { roomId, roomKey } = this.portal;
  148. if (!roomId || !roomKey) {
  149. throw new AbortError();
  150. }
  151. const { savedFiles, erroredFiles } = await saveFilesToFirebase({
  152. prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
  153. files: await encodeFilesForUpload({
  154. files: addedFiles,
  155. encryptionKey: roomKey,
  156. maxBytes: FILE_UPLOAD_MAX_BYTES,
  157. }),
  158. });
  159. return {
  160. savedFiles: savedFiles.reduce(
  161. (acc: Map<FileId, BinaryFileData>, id) => {
  162. const fileData = addedFiles.get(id);
  163. if (fileData) {
  164. acc.set(id, fileData);
  165. }
  166. return acc;
  167. },
  168. new Map(),
  169. ),
  170. erroredFiles: erroredFiles.reduce(
  171. (acc: Map<FileId, BinaryFileData>, id) => {
  172. const fileData = addedFiles.get(id);
  173. if (fileData) {
  174. acc.set(id, fileData);
  175. }
  176. return acc;
  177. },
  178. new Map(),
  179. ),
  180. };
  181. },
  182. });
  183. this.excalidrawAPI = props.excalidrawAPI;
  184. this.activeIntervalId = null;
  185. this.idleTimeoutId = null;
  186. }
  187. private onUmmount: (() => void) | null = null;
  188. componentDidMount() {
  189. window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  190. window.addEventListener("online", this.onOfflineStatusToggle);
  191. window.addEventListener("offline", this.onOfflineStatusToggle);
  192. window.addEventListener(EVENT.UNLOAD, this.onUnload);
  193. const unsubOnUserFollow = this.excalidrawAPI.onUserFollow((payload) => {
  194. this.portal.socket && this.portal.broadcastUserFollowed(payload);
  195. });
  196. const throttledRelayUserViewportBounds = throttleRAF(
  197. this.relayVisibleSceneBounds,
  198. );
  199. const unsubOnScrollChange = this.excalidrawAPI.onScrollChange(() =>
  200. throttledRelayUserViewportBounds(),
  201. );
  202. this.onUmmount = () => {
  203. unsubOnUserFollow();
  204. unsubOnScrollChange();
  205. };
  206. this.onOfflineStatusToggle();
  207. const collabAPI: CollabAPI = {
  208. isCollaborating: this.isCollaborating,
  209. onPointerUpdate: this.onPointerUpdate,
  210. startCollaboration: this.startCollaboration,
  211. syncElements: this.syncElements,
  212. fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
  213. stopCollaboration: this.stopCollaboration,
  214. setUsername: this.setUsername,
  215. getUsername: this.getUsername,
  216. getActiveRoomLink: this.getActiveRoomLink,
  217. setCollabError: this.setErrorDialog,
  218. };
  219. appJotaiStore.set(collabAPIAtom, collabAPI);
  220. if (isTestEnv() || isDevEnv()) {
  221. window.collab = window.collab || ({} as Window["collab"]);
  222. Object.defineProperties(window, {
  223. collab: {
  224. configurable: true,
  225. value: this,
  226. },
  227. });
  228. }
  229. }
  230. onOfflineStatusToggle = () => {
  231. appJotaiStore.set(isOfflineAtom, !window.navigator.onLine);
  232. };
  233. componentWillUnmount() {
  234. window.removeEventListener("online", this.onOfflineStatusToggle);
  235. window.removeEventListener("offline", this.onOfflineStatusToggle);
  236. window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  237. window.removeEventListener(EVENT.UNLOAD, this.onUnload);
  238. window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
  239. window.removeEventListener(
  240. EVENT.VISIBILITY_CHANGE,
  241. this.onVisibilityChange,
  242. );
  243. if (this.activeIntervalId) {
  244. window.clearInterval(this.activeIntervalId);
  245. this.activeIntervalId = null;
  246. }
  247. if (this.idleTimeoutId) {
  248. window.clearTimeout(this.idleTimeoutId);
  249. this.idleTimeoutId = null;
  250. }
  251. this.onUmmount?.();
  252. }
  253. isCollaborating = () => appJotaiStore.get(isCollaboratingAtom)!;
  254. private setIsCollaborating = (isCollaborating: boolean) => {
  255. appJotaiStore.set(isCollaboratingAtom, isCollaborating);
  256. };
  257. private onUnload = () => {
  258. this.destroySocketClient({ isUnload: true });
  259. };
  260. private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
  261. const syncableElements = getSyncableElements(
  262. this.getSceneElementsIncludingDeleted(),
  263. );
  264. if (
  265. this.isCollaborating() &&
  266. (this.fileManager.shouldPreventUnload(syncableElements) ||
  267. !isSavedToFirebase(this.portal, syncableElements))
  268. ) {
  269. // this won't run in time if user decides to leave the site, but
  270. // the purpose is to run in immediately after user decides to stay
  271. this.saveCollabRoomToFirebase(syncableElements);
  272. preventUnload(event);
  273. }
  274. });
  275. saveCollabRoomToFirebase = async (
  276. syncableElements: readonly SyncableExcalidrawElement[],
  277. ) => {
  278. try {
  279. const storedElements = await saveToFirebase(
  280. this.portal,
  281. syncableElements,
  282. this.excalidrawAPI.getAppState(),
  283. );
  284. this.resetErrorIndicator();
  285. if (this.isCollaborating() && storedElements) {
  286. this.handleRemoteSceneUpdate(this._reconcileElements(storedElements));
  287. }
  288. } catch (error: any) {
  289. const errorMessage = /is longer than.*?bytes/.test(error.message)
  290. ? t("errors.collabSaveFailed_sizeExceeded")
  291. : t("errors.collabSaveFailed");
  292. if (
  293. !this.state.dialogNotifiedErrors[errorMessage] ||
  294. !this.isCollaborating()
  295. ) {
  296. this.setErrorDialog(errorMessage);
  297. this.setState({
  298. dialogNotifiedErrors: {
  299. ...this.state.dialogNotifiedErrors,
  300. [errorMessage]: true,
  301. },
  302. });
  303. }
  304. if (this.isCollaborating()) {
  305. this.setErrorIndicator(errorMessage);
  306. }
  307. console.error(error);
  308. }
  309. };
  310. stopCollaboration = (keepRemoteState = true) => {
  311. this.queueBroadcastAllElements.cancel();
  312. this.queueSaveToFirebase.cancel();
  313. this.loadImageFiles.cancel();
  314. this.resetErrorIndicator(true);
  315. this.saveCollabRoomToFirebase(
  316. getSyncableElements(
  317. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  318. ),
  319. );
  320. if (this.portal.socket && this.fallbackInitializationHandler) {
  321. this.portal.socket.off(
  322. "connect_error",
  323. this.fallbackInitializationHandler,
  324. );
  325. }
  326. if (!keepRemoteState) {
  327. LocalData.fileStorage.reset();
  328. this.destroySocketClient();
  329. } else if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
  330. // hack to ensure that we prefer we disregard any new browser state
  331. // that could have been saved in other tabs while we were collaborating
  332. resetBrowserStateVersions();
  333. window.history.pushState({}, APP_NAME, window.location.origin);
  334. this.destroySocketClient();
  335. LocalData.fileStorage.reset();
  336. const elements = this.excalidrawAPI
  337. .getSceneElementsIncludingDeleted()
  338. .map((element) => {
  339. if (isImageElement(element) && element.status === "saved") {
  340. return newElementWith(element, { status: "pending" });
  341. }
  342. return element;
  343. });
  344. this.excalidrawAPI.updateScene({
  345. elements,
  346. captureUpdate: CaptureUpdateAction.NEVER,
  347. });
  348. }
  349. };
  350. private destroySocketClient = (opts?: { isUnload: boolean }) => {
  351. this.lastBroadcastedOrReceivedSceneVersion = -1;
  352. this.portal.close();
  353. this.fileManager.reset();
  354. if (!opts?.isUnload) {
  355. this.setIsCollaborating(false);
  356. this.setActiveRoomLink(null);
  357. this.collaborators = new Map();
  358. this.excalidrawAPI.updateScene({
  359. collaborators: this.collaborators,
  360. });
  361. LocalData.resumeSave("collaboration");
  362. }
  363. };
  364. private fetchImageFilesFromFirebase = async (opts: {
  365. elements: readonly ExcalidrawElement[];
  366. /**
  367. * Indicates whether to fetch files that are errored or pending and older
  368. * than 10 seconds.
  369. *
  370. * Use this as a mechanism to fetch files which may be ok but for some
  371. * reason their status was not updated correctly.
  372. */
  373. forceFetchFiles?: boolean;
  374. }) => {
  375. const unfetchedImages = opts.elements
  376. .filter((element) => {
  377. return (
  378. isInitializedImageElement(element) &&
  379. !this.fileManager.isFileTracked(element.fileId) &&
  380. !element.isDeleted &&
  381. (opts.forceFetchFiles
  382. ? element.status !== "pending" ||
  383. Date.now() - element.updated > 10000
  384. : element.status === "saved")
  385. );
  386. })
  387. .map((element) => (element as InitializedExcalidrawImageElement).fileId);
  388. return await this.fileManager.getFiles(unfetchedImages);
  389. };
  390. private decryptPayload = async (
  391. iv: Uint8Array,
  392. encryptedData: ArrayBuffer,
  393. decryptionKey: string,
  394. ): Promise<ValueOf<SocketUpdateDataSource>> => {
  395. try {
  396. const decrypted = await decryptData(iv, encryptedData, decryptionKey);
  397. const decodedData = new TextDecoder("utf-8").decode(
  398. new Uint8Array(decrypted),
  399. );
  400. return JSON.parse(decodedData);
  401. } catch (error) {
  402. window.alert(t("alerts.decryptFailed"));
  403. console.error(error);
  404. return {
  405. type: WS_SUBTYPES.INVALID_RESPONSE,
  406. };
  407. }
  408. };
  409. private fallbackInitializationHandler: null | (() => any) = null;
  410. startCollaboration = async (
  411. existingRoomLinkData: null | { roomId: string; roomKey: string },
  412. ) => {
  413. if (!this.state.username) {
  414. import("@excalidraw/random-username").then(({ getRandomUsername }) => {
  415. const username = getRandomUsername();
  416. this.setUsername(username);
  417. });
  418. }
  419. if (this.portal.socket) {
  420. return null;
  421. }
  422. let roomId;
  423. let roomKey;
  424. if (existingRoomLinkData) {
  425. ({ roomId, roomKey } = existingRoomLinkData);
  426. } else {
  427. ({ roomId, roomKey } = await generateCollaborationLinkData());
  428. window.history.pushState(
  429. {},
  430. APP_NAME,
  431. getCollaborationLink({ roomId, roomKey }),
  432. );
  433. }
  434. // TODO: `ImportedDataState` type here seems abused
  435. const scenePromise = resolvablePromise<
  436. | (ImportedDataState & { elements: readonly OrderedExcalidrawElement[] })
  437. | null
  438. >();
  439. this.setIsCollaborating(true);
  440. LocalData.pauseSave("collaboration");
  441. const { default: socketIOClient } = await import(
  442. /* webpackChunkName: "socketIoClient" */ "socket.io-client"
  443. );
  444. const fallbackInitializationHandler = () => {
  445. this.initializeRoom({
  446. roomLinkData: existingRoomLinkData,
  447. fetchScene: true,
  448. }).then((scene) => {
  449. scenePromise.resolve(scene);
  450. });
  451. };
  452. this.fallbackInitializationHandler = fallbackInitializationHandler;
  453. try {
  454. this.portal.socket = this.portal.open(
  455. socketIOClient(import.meta.env.VITE_APP_WS_SERVER_URL, {
  456. transports: ["websocket", "polling"],
  457. }),
  458. roomId,
  459. roomKey,
  460. );
  461. this.portal.socket.once("connect_error", fallbackInitializationHandler);
  462. } catch (error: any) {
  463. console.error(error);
  464. this.setErrorDialog(error.message);
  465. return null;
  466. }
  467. if (!existingRoomLinkData) {
  468. const elements = this.excalidrawAPI.getSceneElements().map((element) => {
  469. if (isImageElement(element) && element.status === "saved") {
  470. return newElementWith(element, { status: "pending" });
  471. }
  472. return element;
  473. });
  474. // remove deleted elements from elements array to ensure we don't
  475. // expose potentially sensitive user data in case user manually deletes
  476. // existing elements (or clears scene), which would otherwise be persisted
  477. // to database even if deleted before creating the room.
  478. this.excalidrawAPI.updateScene({
  479. elements,
  480. captureUpdate: CaptureUpdateAction.NEVER,
  481. });
  482. this.saveCollabRoomToFirebase(getSyncableElements(elements));
  483. }
  484. // fallback in case you're not alone in the room but still don't receive
  485. // initial SCENE_INIT message
  486. this.socketInitializationTimer = window.setTimeout(
  487. fallbackInitializationHandler,
  488. INITIAL_SCENE_UPDATE_TIMEOUT,
  489. );
  490. // All socket listeners are moving to Portal
  491. this.portal.socket.on(
  492. "client-broadcast",
  493. async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
  494. if (!this.portal.roomKey) {
  495. return;
  496. }
  497. const decryptedData = await this.decryptPayload(
  498. iv,
  499. encryptedData,
  500. this.portal.roomKey,
  501. );
  502. switch (decryptedData.type) {
  503. case WS_SUBTYPES.INVALID_RESPONSE:
  504. return;
  505. case WS_SUBTYPES.INIT: {
  506. if (!this.portal.socketInitialized) {
  507. this.initializeRoom({ fetchScene: false });
  508. const remoteElements = decryptedData.payload.elements;
  509. const reconciledElements =
  510. this._reconcileElements(remoteElements);
  511. this.handleRemoteSceneUpdate(reconciledElements);
  512. // noop if already resolved via init from firebase
  513. scenePromise.resolve({
  514. elements: reconciledElements,
  515. scrollToContent: true,
  516. });
  517. }
  518. break;
  519. }
  520. case WS_SUBTYPES.UPDATE:
  521. this.handleRemoteSceneUpdate(
  522. this._reconcileElements(decryptedData.payload.elements),
  523. );
  524. break;
  525. case WS_SUBTYPES.MOUSE_LOCATION: {
  526. const { pointer, button, username, selectedElementIds } =
  527. decryptedData.payload;
  528. const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
  529. decryptedData.payload.socketId ||
  530. // @ts-ignore legacy, see #2094 (#2097)
  531. decryptedData.payload.socketID;
  532. this.updateCollaborator(socketId, {
  533. pointer,
  534. button,
  535. selectedElementIds,
  536. username,
  537. });
  538. break;
  539. }
  540. case WS_SUBTYPES.USER_VISIBLE_SCENE_BOUNDS: {
  541. const { sceneBounds, socketId } = decryptedData.payload;
  542. const appState = this.excalidrawAPI.getAppState();
  543. // we're not following the user
  544. // (shouldn't happen, but could be late message or bug upstream)
  545. if (appState.userToFollow?.socketId !== socketId) {
  546. console.warn(
  547. `receiving remote client's (from ${socketId}) viewport bounds even though we're not subscribed to it!`,
  548. );
  549. return;
  550. }
  551. // cross-follow case, ignore updates in this case
  552. if (
  553. appState.userToFollow &&
  554. appState.followedBy.has(appState.userToFollow.socketId)
  555. ) {
  556. return;
  557. }
  558. this.excalidrawAPI.updateScene({
  559. appState: zoomToFitBounds({
  560. appState,
  561. bounds: sceneBounds,
  562. fitToViewport: true,
  563. viewportZoomFactor: 1,
  564. }).appState,
  565. });
  566. break;
  567. }
  568. case WS_SUBTYPES.IDLE_STATUS: {
  569. const { userState, socketId, username } = decryptedData.payload;
  570. this.updateCollaborator(socketId, {
  571. userState,
  572. username,
  573. });
  574. break;
  575. }
  576. default: {
  577. assertNever(decryptedData, null);
  578. }
  579. }
  580. },
  581. );
  582. this.portal.socket.on("first-in-room", async () => {
  583. if (this.portal.socket) {
  584. this.portal.socket.off("first-in-room");
  585. }
  586. const sceneData = await this.initializeRoom({
  587. fetchScene: true,
  588. roomLinkData: existingRoomLinkData,
  589. });
  590. scenePromise.resolve(sceneData);
  591. });
  592. this.portal.socket.on(
  593. WS_EVENTS.USER_FOLLOW_ROOM_CHANGE,
  594. (followedBy: SocketId[]) => {
  595. this.excalidrawAPI.updateScene({
  596. appState: { followedBy: new Set(followedBy) },
  597. });
  598. this.relayVisibleSceneBounds({ force: true });
  599. },
  600. );
  601. this.initializeIdleDetector();
  602. this.setActiveRoomLink(window.location.href);
  603. return scenePromise;
  604. };
  605. private initializeRoom = async ({
  606. fetchScene,
  607. roomLinkData,
  608. }:
  609. | {
  610. fetchScene: true;
  611. roomLinkData: { roomId: string; roomKey: string } | null;
  612. }
  613. | { fetchScene: false; roomLinkData?: null }) => {
  614. clearTimeout(this.socketInitializationTimer!);
  615. if (this.portal.socket && this.fallbackInitializationHandler) {
  616. this.portal.socket.off(
  617. "connect_error",
  618. this.fallbackInitializationHandler,
  619. );
  620. }
  621. if (fetchScene && roomLinkData && this.portal.socket) {
  622. this.excalidrawAPI.resetScene();
  623. try {
  624. const elements = await loadFromFirebase(
  625. roomLinkData.roomId,
  626. roomLinkData.roomKey,
  627. this.portal.socket,
  628. );
  629. if (elements) {
  630. this.setLastBroadcastedOrReceivedSceneVersion(
  631. getSceneVersion(elements),
  632. );
  633. return {
  634. elements,
  635. scrollToContent: true,
  636. };
  637. }
  638. } catch (error: any) {
  639. // log the error and move on. other peers will sync us the scene.
  640. console.error(error);
  641. } finally {
  642. this.portal.socketInitialized = true;
  643. }
  644. } else {
  645. this.portal.socketInitialized = true;
  646. }
  647. return null;
  648. };
  649. private _reconcileElements = (
  650. remoteElements: readonly ExcalidrawElement[],
  651. ): ReconciledExcalidrawElement[] => {
  652. const localElements = this.getSceneElementsIncludingDeleted();
  653. const appState = this.excalidrawAPI.getAppState();
  654. const restoredRemoteElements = restoreElements(remoteElements, null);
  655. const reconciledElements = reconcileElements(
  656. localElements,
  657. restoredRemoteElements as RemoteExcalidrawElement[],
  658. appState,
  659. );
  660. // Avoid broadcasting to the rest of the collaborators the scene
  661. // we just received!
  662. // Note: this needs to be set before updating the scene as it
  663. // synchronously calls render.
  664. this.setLastBroadcastedOrReceivedSceneVersion(
  665. getSceneVersion(reconciledElements),
  666. );
  667. return reconciledElements;
  668. };
  669. private loadImageFiles = throttle(async () => {
  670. const { loadedFiles, erroredFiles } =
  671. await this.fetchImageFilesFromFirebase({
  672. elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  673. });
  674. this.excalidrawAPI.addFiles(loadedFiles);
  675. updateStaleImageStatuses({
  676. excalidrawAPI: this.excalidrawAPI,
  677. erroredFiles,
  678. elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  679. });
  680. }, LOAD_IMAGES_TIMEOUT);
  681. private handleRemoteSceneUpdate = (
  682. elements: ReconciledExcalidrawElement[],
  683. ) => {
  684. this.excalidrawAPI.updateScene({
  685. elements,
  686. captureUpdate: CaptureUpdateAction.NEVER,
  687. });
  688. this.loadImageFiles();
  689. };
  690. private onPointerMove = () => {
  691. if (this.idleTimeoutId) {
  692. window.clearTimeout(this.idleTimeoutId);
  693. this.idleTimeoutId = null;
  694. }
  695. this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
  696. if (!this.activeIntervalId) {
  697. this.activeIntervalId = window.setInterval(
  698. this.reportActive,
  699. ACTIVE_THRESHOLD,
  700. );
  701. }
  702. };
  703. private onVisibilityChange = () => {
  704. if (document.hidden) {
  705. if (this.idleTimeoutId) {
  706. window.clearTimeout(this.idleTimeoutId);
  707. this.idleTimeoutId = null;
  708. }
  709. if (this.activeIntervalId) {
  710. window.clearInterval(this.activeIntervalId);
  711. this.activeIntervalId = null;
  712. }
  713. this.onIdleStateChange(UserIdleState.AWAY);
  714. } else {
  715. this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
  716. this.activeIntervalId = window.setInterval(
  717. this.reportActive,
  718. ACTIVE_THRESHOLD,
  719. );
  720. this.onIdleStateChange(UserIdleState.ACTIVE);
  721. }
  722. };
  723. private reportIdle = () => {
  724. this.onIdleStateChange(UserIdleState.IDLE);
  725. if (this.activeIntervalId) {
  726. window.clearInterval(this.activeIntervalId);
  727. this.activeIntervalId = null;
  728. }
  729. };
  730. private reportActive = () => {
  731. this.onIdleStateChange(UserIdleState.ACTIVE);
  732. };
  733. private initializeIdleDetector = () => {
  734. document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
  735. document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
  736. };
  737. setCollaborators(sockets: SocketId[]) {
  738. const collaborators: InstanceType<typeof Collab>["collaborators"] =
  739. new Map();
  740. for (const socketId of sockets) {
  741. collaborators.set(
  742. socketId,
  743. Object.assign({}, this.collaborators.get(socketId), {
  744. isCurrentUser: socketId === this.portal.socket?.id,
  745. }),
  746. );
  747. }
  748. this.collaborators = collaborators;
  749. this.excalidrawAPI.updateScene({ collaborators });
  750. }
  751. updateCollaborator = (socketId: SocketId, updates: Partial<Collaborator>) => {
  752. const collaborators = new Map(this.collaborators);
  753. const user: Mutable<Collaborator> = Object.assign(
  754. {},
  755. collaborators.get(socketId),
  756. updates,
  757. {
  758. isCurrentUser: socketId === this.portal.socket?.id,
  759. },
  760. );
  761. collaborators.set(socketId, user);
  762. this.collaborators = collaborators;
  763. this.excalidrawAPI.updateScene({
  764. collaborators,
  765. });
  766. };
  767. public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
  768. this.lastBroadcastedOrReceivedSceneVersion = version;
  769. };
  770. public getLastBroadcastedOrReceivedSceneVersion = () => {
  771. return this.lastBroadcastedOrReceivedSceneVersion;
  772. };
  773. public getSceneElementsIncludingDeleted = () => {
  774. return this.excalidrawAPI.getSceneElementsIncludingDeleted();
  775. };
  776. onPointerUpdate = throttle(
  777. (payload: {
  778. pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
  779. button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
  780. pointersMap: Gesture["pointers"];
  781. }) => {
  782. payload.pointersMap.size < 2 &&
  783. this.portal.socket &&
  784. this.portal.broadcastMouseLocation(payload);
  785. },
  786. CURSOR_SYNC_TIMEOUT,
  787. );
  788. relayVisibleSceneBounds = (props?: { force: boolean }) => {
  789. const appState = this.excalidrawAPI.getAppState();
  790. if (this.portal.socket && (appState.followedBy.size > 0 || props?.force)) {
  791. this.portal.broadcastVisibleSceneBounds(
  792. {
  793. sceneBounds: getVisibleSceneBounds(appState),
  794. },
  795. `follow@${this.portal.socket.id}`,
  796. );
  797. }
  798. };
  799. onIdleStateChange = (userState: UserIdleState) => {
  800. this.portal.broadcastIdleChange(userState);
  801. };
  802. broadcastElements = (elements: readonly OrderedExcalidrawElement[]) => {
  803. if (
  804. getSceneVersion(elements) >
  805. this.getLastBroadcastedOrReceivedSceneVersion()
  806. ) {
  807. this.portal.broadcastScene(WS_SUBTYPES.UPDATE, elements, false);
  808. this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
  809. this.queueBroadcastAllElements();
  810. }
  811. };
  812. syncElements = (elements: readonly OrderedExcalidrawElement[]) => {
  813. this.broadcastElements(elements);
  814. this.queueSaveToFirebase();
  815. };
  816. queueBroadcastAllElements = throttle(() => {
  817. this.portal.broadcastScene(
  818. WS_SUBTYPES.UPDATE,
  819. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  820. true,
  821. );
  822. const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
  823. const newVersion = Math.max(
  824. currentVersion,
  825. getSceneVersion(this.getSceneElementsIncludingDeleted()),
  826. );
  827. this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
  828. }, SYNC_FULL_SCENE_INTERVAL_MS);
  829. queueSaveToFirebase = throttle(
  830. () => {
  831. if (this.portal.socketInitialized) {
  832. this.saveCollabRoomToFirebase(
  833. getSyncableElements(
  834. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  835. ),
  836. );
  837. }
  838. },
  839. SYNC_FULL_SCENE_INTERVAL_MS,
  840. { leading: false },
  841. );
  842. setUsername = (username: string) => {
  843. this.setState({ username });
  844. saveUsernameToLocalStorage(username);
  845. };
  846. getUsername = () => this.state.username;
  847. setActiveRoomLink = (activeRoomLink: string | null) => {
  848. this.setState({ activeRoomLink });
  849. appJotaiStore.set(activeRoomLinkAtom, activeRoomLink);
  850. };
  851. getActiveRoomLink = () => this.state.activeRoomLink;
  852. setErrorIndicator = (errorMessage: string | null) => {
  853. appJotaiStore.set(collabErrorIndicatorAtom, {
  854. message: errorMessage,
  855. nonce: Date.now(),
  856. });
  857. };
  858. resetErrorIndicator = (resetDialogNotifiedErrors = false) => {
  859. appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 });
  860. if (resetDialogNotifiedErrors) {
  861. this.setState({
  862. dialogNotifiedErrors: {},
  863. });
  864. }
  865. };
  866. setErrorDialog = (errorMessage: string | null) => {
  867. this.setState({
  868. errorMessage,
  869. });
  870. };
  871. render() {
  872. const { errorMessage } = this.state;
  873. return (
  874. <>
  875. {errorMessage != null && (
  876. <ErrorDialog onClose={() => this.setErrorDialog(null)}>
  877. {errorMessage}
  878. </ErrorDialog>
  879. )}
  880. </>
  881. );
  882. }
  883. }
  884. declare global {
  885. interface Window {
  886. collab: InstanceType<typeof Collab>;
  887. }
  888. }
  889. if (isTestEnv() || isDevEnv()) {
  890. window.collab = window.collab || ({} as Window["collab"]);
  891. }
  892. export default Collab;
  893. export type TCollabClass = Collab;