Collab.tsx 28 KB

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