Collab.tsx 26 KB

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