CollabWrapper.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569
  1. import throttle from "lodash.throttle";
  2. import React, { PureComponent } from "react";
  3. import { ExcalidrawImperativeAPI } from "../../components/App";
  4. import { ErrorDialog } from "../../components/ErrorDialog";
  5. import { APP_NAME, ENV, EVENT } from "../../constants";
  6. import { ImportedDataState } from "../../data/types";
  7. import { ExcalidrawElement } from "../../element/types";
  8. import {
  9. getElementMap,
  10. getSceneVersion,
  11. getSyncableElements,
  12. } from "../../packages/excalidraw/index";
  13. import { Collaborator, Gesture } from "../../types";
  14. import { resolvablePromise, withBatchedUpdates } from "../../utils";
  15. import {
  16. INITIAL_SCENE_UPDATE_TIMEOUT,
  17. SCENE,
  18. SYNC_FULL_SCENE_INTERVAL_MS,
  19. } from "../app_constants";
  20. import {
  21. decryptAESGEM,
  22. generateCollaborationLinkData,
  23. getCollaborationLink,
  24. SocketUpdateDataSource,
  25. SOCKET_SERVER,
  26. } from "../data";
  27. import {
  28. isSavedToFirebase,
  29. loadFromFirebase,
  30. saveToFirebase,
  31. } from "../data/firebase";
  32. import {
  33. importUsernameFromLocalStorage,
  34. saveUsernameToLocalStorage,
  35. STORAGE_KEYS,
  36. } from "../data/localStorage";
  37. import Portal from "./Portal";
  38. import RoomDialog from "./RoomDialog";
  39. import { createInverseContext } from "../../createInverseContext";
  40. import { t } from "../../i18n";
  41. interface CollabState {
  42. modalIsShown: boolean;
  43. errorMessage: string;
  44. username: string;
  45. activeRoomLink: string;
  46. }
  47. type CollabInstance = InstanceType<typeof CollabWrapper>;
  48. export interface CollabAPI {
  49. /** function so that we can access the latest value from stale callbacks */
  50. isCollaborating: () => boolean;
  51. username: CollabState["username"];
  52. onPointerUpdate: CollabInstance["onPointerUpdate"];
  53. initializeSocketClient: CollabInstance["initializeSocketClient"];
  54. onCollabButtonClick: CollabInstance["onCollabButtonClick"];
  55. broadcastElements: CollabInstance["broadcastElements"];
  56. }
  57. type ReconciledElements = readonly ExcalidrawElement[] & {
  58. _brand: "reconciledElements";
  59. };
  60. interface Props {
  61. excalidrawAPI: ExcalidrawImperativeAPI;
  62. }
  63. const {
  64. Context: CollabContext,
  65. Consumer: CollabContextConsumer,
  66. Provider: CollabContextProvider,
  67. } = createInverseContext<{ api: CollabAPI | null }>({ api: null });
  68. export { CollabContext, CollabContextConsumer };
  69. class CollabWrapper extends PureComponent<Props, CollabState> {
  70. portal: Portal;
  71. excalidrawAPI: Props["excalidrawAPI"];
  72. isCollaborating: boolean = false;
  73. private socketInitializationTimer?: NodeJS.Timeout;
  74. private lastBroadcastedOrReceivedSceneVersion: number = -1;
  75. private collaborators = new Map<string, Collaborator>();
  76. constructor(props: Props) {
  77. super(props);
  78. this.state = {
  79. modalIsShown: false,
  80. errorMessage: "",
  81. username: importUsernameFromLocalStorage() || "",
  82. activeRoomLink: "",
  83. };
  84. this.portal = new Portal(this);
  85. this.excalidrawAPI = props.excalidrawAPI;
  86. }
  87. componentDidMount() {
  88. window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  89. window.addEventListener(EVENT.UNLOAD, this.onUnload);
  90. if (
  91. process.env.NODE_ENV === ENV.TEST ||
  92. process.env.NODE_ENV === ENV.DEVELOPMENT
  93. ) {
  94. window.h = window.h || ({} as Window["h"]);
  95. Object.defineProperties(window.h, {
  96. collab: {
  97. configurable: true,
  98. value: this,
  99. },
  100. });
  101. }
  102. }
  103. componentWillUnmount() {
  104. window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
  105. window.removeEventListener(EVENT.UNLOAD, this.onUnload);
  106. }
  107. private onUnload = () => {
  108. this.destroySocketClient({ isUnload: true });
  109. };
  110. private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
  111. const syncableElements = getSyncableElements(
  112. this.getSceneElementsIncludingDeleted(),
  113. );
  114. if (
  115. this.isCollaborating &&
  116. !isSavedToFirebase(this.portal, syncableElements)
  117. ) {
  118. // this won't run in time if user decides to leave the site, but
  119. // the purpose is to run in immediately after user decides to stay
  120. this.saveCollabRoomToFirebase(syncableElements);
  121. event.preventDefault();
  122. // NOTE: modern browsers no longer allow showing a custom message here
  123. event.returnValue = "";
  124. }
  125. if (this.isCollaborating || this.portal.roomId) {
  126. try {
  127. localStorage?.setItem(
  128. STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
  129. JSON.stringify({
  130. timestamp: Date.now(),
  131. room: this.portal.roomId,
  132. }),
  133. );
  134. } catch {}
  135. }
  136. });
  137. saveCollabRoomToFirebase = async (
  138. syncableElements: ExcalidrawElement[] = getSyncableElements(
  139. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  140. ),
  141. ) => {
  142. try {
  143. await saveToFirebase(this.portal, syncableElements);
  144. } catch (error) {
  145. console.error(error);
  146. }
  147. };
  148. openPortal = async () => {
  149. return this.initializeSocketClient(null);
  150. };
  151. closePortal = () => {
  152. this.saveCollabRoomToFirebase();
  153. if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
  154. window.history.pushState({}, APP_NAME, window.location.origin);
  155. this.destroySocketClient();
  156. }
  157. };
  158. private destroySocketClient = (opts?: { isUnload: boolean }) => {
  159. if (!opts?.isUnload) {
  160. this.collaborators = new Map();
  161. this.excalidrawAPI.updateScene({
  162. collaborators: this.collaborators,
  163. });
  164. this.setState({
  165. activeRoomLink: "",
  166. });
  167. this.isCollaborating = false;
  168. }
  169. this.portal.close();
  170. };
  171. private initializeSocketClient = async (
  172. existingRoomLinkData: null | { roomId: string; roomKey: string },
  173. ): Promise<ImportedDataState | null> => {
  174. if (this.portal.socket) {
  175. return null;
  176. }
  177. let roomId;
  178. let roomKey;
  179. if (existingRoomLinkData) {
  180. ({ roomId, roomKey } = existingRoomLinkData);
  181. } else {
  182. ({ roomId, roomKey } = await generateCollaborationLinkData());
  183. window.history.pushState(
  184. {},
  185. APP_NAME,
  186. getCollaborationLink({ roomId, roomKey }),
  187. );
  188. }
  189. const scenePromise = resolvablePromise<ImportedDataState | null>();
  190. this.isCollaborating = true;
  191. const { default: socketIOClient }: any = await import(
  192. /* webpackChunkName: "socketIoClient" */ "socket.io-client"
  193. );
  194. this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
  195. if (existingRoomLinkData) {
  196. this.excalidrawAPI.resetScene();
  197. try {
  198. const elements = await loadFromFirebase(
  199. roomId,
  200. roomKey,
  201. this.portal.socket,
  202. );
  203. if (elements) {
  204. scenePromise.resolve({
  205. elements,
  206. });
  207. }
  208. } catch (error) {
  209. // log the error and move on. other peers will sync us the scene.
  210. console.error(error);
  211. }
  212. } else {
  213. const elements = this.excalidrawAPI.getSceneElements();
  214. // remove deleted elements from elements array & history to ensure we don't
  215. // expose potentially sensitive user data in case user manually deletes
  216. // existing elements (or clears scene), which would otherwise be persisted
  217. // to database even if deleted before creating the room.
  218. this.excalidrawAPI.history.clear();
  219. this.excalidrawAPI.updateScene({
  220. elements,
  221. commitToHistory: true,
  222. });
  223. }
  224. // fallback in case you're not alone in the room but still don't receive
  225. // initial SCENE_UPDATE message
  226. this.socketInitializationTimer = setTimeout(() => {
  227. this.initializeSocket();
  228. scenePromise.resolve(null);
  229. }, INITIAL_SCENE_UPDATE_TIMEOUT);
  230. // All socket listeners are moving to Portal
  231. this.portal.socket!.on(
  232. "client-broadcast",
  233. async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
  234. if (!this.portal.roomKey) {
  235. return;
  236. }
  237. const decryptedData = await decryptAESGEM(
  238. encryptedData,
  239. this.portal.roomKey,
  240. iv,
  241. );
  242. switch (decryptedData.type) {
  243. case "INVALID_RESPONSE":
  244. return;
  245. case SCENE.INIT: {
  246. if (!this.portal.socketInitialized) {
  247. this.initializeSocket();
  248. const remoteElements = decryptedData.payload.elements;
  249. const reconciledElements = this.reconcileElements(remoteElements);
  250. this.handleRemoteSceneUpdate(reconciledElements, {
  251. init: true,
  252. });
  253. // noop if already resolved via init from firebase
  254. scenePromise.resolve({ elements: reconciledElements });
  255. }
  256. break;
  257. }
  258. case SCENE.UPDATE:
  259. this.handleRemoteSceneUpdate(
  260. this.reconcileElements(decryptedData.payload.elements),
  261. );
  262. break;
  263. case "MOUSE_LOCATION": {
  264. const {
  265. pointer,
  266. button,
  267. username,
  268. selectedElementIds,
  269. } = decryptedData.payload;
  270. const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
  271. decryptedData.payload.socketId ||
  272. // @ts-ignore legacy, see #2094 (#2097)
  273. decryptedData.payload.socketID;
  274. const collaborators = new Map(this.collaborators);
  275. const user = collaborators.get(socketId) || {}!;
  276. user.pointer = pointer;
  277. user.button = button;
  278. user.selectedElementIds = selectedElementIds;
  279. user.username = username;
  280. collaborators.set(socketId, user);
  281. this.excalidrawAPI.updateScene({
  282. collaborators,
  283. });
  284. break;
  285. }
  286. }
  287. },
  288. );
  289. this.portal.socket!.on("first-in-room", () => {
  290. if (this.portal.socket) {
  291. this.portal.socket.off("first-in-room");
  292. }
  293. this.initializeSocket();
  294. scenePromise.resolve(null);
  295. });
  296. this.setState({
  297. activeRoomLink: window.location.href,
  298. });
  299. return scenePromise;
  300. };
  301. private initializeSocket = () => {
  302. this.portal.socketInitialized = true;
  303. clearTimeout(this.socketInitializationTimer!);
  304. };
  305. private reconcileElements = (
  306. elements: readonly ExcalidrawElement[],
  307. ): ReconciledElements => {
  308. const currentElements = this.getSceneElementsIncludingDeleted();
  309. // create a map of ids so we don't have to iterate
  310. // over the array more than once.
  311. const localElementMap = getElementMap(currentElements);
  312. const appState = this.excalidrawAPI.getAppState();
  313. // Reconcile
  314. const newElements: readonly ExcalidrawElement[] = elements
  315. .reduce((elements, element) => {
  316. // if the remote element references one that's currently
  317. // edited on local, skip it (it'll be added in the next step)
  318. if (
  319. element.id === appState.editingElement?.id ||
  320. element.id === appState.resizingElement?.id ||
  321. element.id === appState.draggingElement?.id
  322. ) {
  323. return elements;
  324. }
  325. if (
  326. localElementMap.hasOwnProperty(element.id) &&
  327. localElementMap[element.id].version > element.version
  328. ) {
  329. elements.push(localElementMap[element.id]);
  330. delete localElementMap[element.id];
  331. } else if (
  332. localElementMap.hasOwnProperty(element.id) &&
  333. localElementMap[element.id].version === element.version &&
  334. localElementMap[element.id].versionNonce !== element.versionNonce
  335. ) {
  336. // resolve conflicting edits deterministically by taking the one with the lowest versionNonce
  337. if (localElementMap[element.id].versionNonce < element.versionNonce) {
  338. elements.push(localElementMap[element.id]);
  339. } else {
  340. // it should be highly unlikely that the two versionNonces are the same. if we are
  341. // really worried about this, we can replace the versionNonce with the socket id.
  342. elements.push(element);
  343. }
  344. delete localElementMap[element.id];
  345. } else {
  346. elements.push(element);
  347. delete localElementMap[element.id];
  348. }
  349. return elements;
  350. }, [] as Mutable<typeof elements>)
  351. // add local elements that weren't deleted or on remote
  352. .concat(...Object.values(localElementMap));
  353. // Avoid broadcasting to the rest of the collaborators the scene
  354. // we just received!
  355. // Note: this needs to be set before updating the scene as it
  356. // syncronously calls render.
  357. this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
  358. return newElements as ReconciledElements;
  359. };
  360. private handleRemoteSceneUpdate = (
  361. elements: ReconciledElements,
  362. {
  363. init = false,
  364. initFromSnapshot = false,
  365. }: { init?: boolean; initFromSnapshot?: boolean } = {},
  366. ) => {
  367. if (init || initFromSnapshot) {
  368. this.excalidrawAPI.setScrollToCenter(elements);
  369. }
  370. this.excalidrawAPI.updateScene({
  371. elements,
  372. commitToHistory: !!init,
  373. });
  374. // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
  375. // when we receive any messages from another peer. This UX can be pretty rough -- if you
  376. // undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
  377. // right now we think this is the right tradeoff.
  378. this.excalidrawAPI.history.clear();
  379. };
  380. setCollaborators(sockets: string[]) {
  381. this.setState((state) => {
  382. const collaborators: InstanceType<
  383. typeof CollabWrapper
  384. >["collaborators"] = new Map();
  385. for (const socketId of sockets) {
  386. if (this.collaborators.has(socketId)) {
  387. collaborators.set(socketId, this.collaborators.get(socketId)!);
  388. } else {
  389. collaborators.set(socketId, {});
  390. }
  391. }
  392. this.collaborators = collaborators;
  393. this.excalidrawAPI.updateScene({ collaborators });
  394. });
  395. }
  396. public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
  397. this.lastBroadcastedOrReceivedSceneVersion = version;
  398. };
  399. public getLastBroadcastedOrReceivedSceneVersion = () => {
  400. return this.lastBroadcastedOrReceivedSceneVersion;
  401. };
  402. public getSceneElementsIncludingDeleted = () => {
  403. return this.excalidrawAPI.getSceneElementsIncludingDeleted();
  404. };
  405. onPointerUpdate = (payload: {
  406. pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
  407. button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
  408. pointersMap: Gesture["pointers"];
  409. }) => {
  410. payload.pointersMap.size < 2 &&
  411. this.portal.socket &&
  412. this.portal.broadcastMouseLocation(payload);
  413. };
  414. broadcastElements = (elements: readonly ExcalidrawElement[]) => {
  415. if (
  416. getSceneVersion(elements) >
  417. this.getLastBroadcastedOrReceivedSceneVersion()
  418. ) {
  419. this.portal.broadcastScene(
  420. SCENE.UPDATE,
  421. getSyncableElements(elements),
  422. false,
  423. );
  424. this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
  425. this.queueBroadcastAllElements();
  426. }
  427. };
  428. queueBroadcastAllElements = throttle(() => {
  429. this.portal.broadcastScene(
  430. SCENE.UPDATE,
  431. getSyncableElements(
  432. this.excalidrawAPI.getSceneElementsIncludingDeleted(),
  433. ),
  434. true,
  435. );
  436. const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
  437. const newVersion = Math.max(
  438. currentVersion,
  439. getSceneVersion(this.getSceneElementsIncludingDeleted()),
  440. );
  441. this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
  442. }, SYNC_FULL_SCENE_INTERVAL_MS);
  443. handleClose = () => {
  444. this.setState({ modalIsShown: false });
  445. const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
  446. collabIcon.focus();
  447. };
  448. onUsernameChange = (username: string) => {
  449. this.setState({ username });
  450. saveUsernameToLocalStorage(username);
  451. };
  452. onCollabButtonClick = () => {
  453. this.setState({
  454. modalIsShown: true,
  455. });
  456. };
  457. /** PRIVATE. Use `this.getContextValue()` instead. */
  458. private contextValue: CollabAPI | null = null;
  459. /** Getter of context value. Returned object is stable. */
  460. getContextValue = (): CollabAPI => {
  461. if (!this.contextValue) {
  462. this.contextValue = {} as CollabAPI;
  463. }
  464. this.contextValue.isCollaborating = () => this.isCollaborating;
  465. this.contextValue.username = this.state.username;
  466. this.contextValue.onPointerUpdate = this.onPointerUpdate;
  467. this.contextValue.initializeSocketClient = this.initializeSocketClient;
  468. this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
  469. this.contextValue.broadcastElements = this.broadcastElements;
  470. return this.contextValue;
  471. };
  472. render() {
  473. const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
  474. return (
  475. <>
  476. {modalIsShown && (
  477. <RoomDialog
  478. handleClose={this.handleClose}
  479. activeRoomLink={activeRoomLink}
  480. username={username}
  481. onUsernameChange={this.onUsernameChange}
  482. onRoomCreate={this.openPortal}
  483. onRoomDestroy={this.closePortal}
  484. setErrorMessage={(errorMessage) => {
  485. this.setState({ errorMessage });
  486. }}
  487. />
  488. )}
  489. {errorMessage && (
  490. <ErrorDialog
  491. message={errorMessage}
  492. onClose={() => this.setState({ errorMessage: "" })}
  493. />
  494. )}
  495. <CollabContextProvider
  496. value={{
  497. api: this.getContextValue(),
  498. }}
  499. />
  500. </>
  501. );
  502. }
  503. }
  504. export default CollabWrapper;