Collab.tsx 30 KB

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