Collab.tsx 30 KB

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