2
0

Collab.tsx 26 KB

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