You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Portal.tsx 6.4KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import {
  2. encryptAESGEM,
  3. SocketUpdateData,
  4. SocketUpdateDataSource,
  5. } from "../data";
  6. import CollabWrapper from "./CollabWrapper";
  7. import { ExcalidrawElement } from "../../element/types";
  8. import { BROADCAST, FILE_UPLOAD_TIMEOUT, SCENE } from "../app_constants";
  9. import { UserIdleState } from "../../types";
  10. import { trackEvent } from "../../analytics";
  11. import { throttle } from "lodash";
  12. import { newElementWith } from "../../element/mutateElement";
  13. import { BroadcastedExcalidrawElement } from "./reconciliation";
  14. class Portal {
  15. collab: CollabWrapper;
  16. socket: SocketIOClient.Socket | null = null;
  17. socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
  18. roomId: string | null = null;
  19. roomKey: string | null = null;
  20. broadcastedElementVersions: Map<string, number> = new Map();
  21. constructor(collab: CollabWrapper) {
  22. this.collab = collab;
  23. }
  24. open(socket: SocketIOClient.Socket, id: string, key: string) {
  25. this.socket = socket;
  26. this.roomId = id;
  27. this.roomKey = key;
  28. // Initialize socket listeners
  29. this.socket.on("init-room", () => {
  30. if (this.socket) {
  31. this.socket.emit("join-room", this.roomId);
  32. trackEvent("share", "room joined");
  33. }
  34. });
  35. this.socket.on("new-user", async (_socketId: string) => {
  36. this.broadcastScene(
  37. SCENE.INIT,
  38. this.collab.getSceneElementsIncludingDeleted(),
  39. /* syncAll */ true,
  40. );
  41. });
  42. this.socket.on("room-user-change", (clients: string[]) => {
  43. this.collab.setCollaborators(clients);
  44. });
  45. }
  46. close() {
  47. if (!this.socket) {
  48. return;
  49. }
  50. this.queueFileUpload.flush();
  51. this.socket.close();
  52. this.socket = null;
  53. this.roomId = null;
  54. this.roomKey = null;
  55. this.socketInitialized = false;
  56. this.broadcastedElementVersions = new Map();
  57. }
  58. isOpen() {
  59. return !!(
  60. this.socketInitialized &&
  61. this.socket &&
  62. this.roomId &&
  63. this.roomKey
  64. );
  65. }
  66. async _broadcastSocketData(
  67. data: SocketUpdateData,
  68. volatile: boolean = false,
  69. ) {
  70. if (this.isOpen()) {
  71. const json = JSON.stringify(data);
  72. const encoded = new TextEncoder().encode(json);
  73. const encrypted = await encryptAESGEM(encoded, this.roomKey!);
  74. this.socket?.emit(
  75. volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
  76. this.roomId,
  77. encrypted.data,
  78. encrypted.iv,
  79. );
  80. }
  81. }
  82. queueFileUpload = throttle(async () => {
  83. try {
  84. await this.collab.fileManager.saveFiles({
  85. elements: this.collab.excalidrawAPI.getSceneElementsIncludingDeleted(),
  86. files: this.collab.excalidrawAPI.getFiles(),
  87. });
  88. } catch (error) {
  89. if (error.name !== "AbortError") {
  90. this.collab.excalidrawAPI.updateScene({
  91. appState: {
  92. errorMessage: error.message,
  93. },
  94. });
  95. }
  96. }
  97. this.collab.excalidrawAPI.updateScene({
  98. elements: this.collab.excalidrawAPI
  99. .getSceneElementsIncludingDeleted()
  100. .map((element) => {
  101. if (this.collab.fileManager.shouldUpdateImageElementStatus(element)) {
  102. // this will signal collaborators to pull image data from server
  103. // (using mutation instead of newElementWith otherwise it'd break
  104. // in-progress dragging)
  105. return newElementWith(element, { status: "saved" });
  106. }
  107. return element;
  108. }),
  109. });
  110. }, FILE_UPLOAD_TIMEOUT);
  111. broadcastScene = async (
  112. sceneType: SCENE.INIT | SCENE.UPDATE,
  113. allElements: readonly ExcalidrawElement[],
  114. syncAll: boolean,
  115. ) => {
  116. if (sceneType === SCENE.INIT && !syncAll) {
  117. throw new Error("syncAll must be true when sending SCENE.INIT");
  118. }
  119. // sync out only the elements we think we need to to save bandwidth.
  120. // periodically we'll resync the whole thing to make sure no one diverges
  121. // due to a dropped message (server goes down etc).
  122. const syncableElements = allElements.reduce(
  123. (acc, element: BroadcastedExcalidrawElement, idx, elements) => {
  124. if (
  125. (syncAll ||
  126. !this.broadcastedElementVersions.has(element.id) ||
  127. element.version >
  128. this.broadcastedElementVersions.get(element.id)!) &&
  129. this.collab.isSyncableElement(element)
  130. ) {
  131. acc.push({
  132. ...element,
  133. // z-index info for the reconciler
  134. parent: idx === 0 ? "^" : elements[idx - 1]?.id,
  135. });
  136. }
  137. return acc;
  138. },
  139. [] as BroadcastedExcalidrawElement[],
  140. );
  141. const data: SocketUpdateDataSource[typeof sceneType] = {
  142. type: sceneType,
  143. payload: {
  144. elements: syncableElements,
  145. },
  146. };
  147. for (const syncableElement of syncableElements) {
  148. this.broadcastedElementVersions.set(
  149. syncableElement.id,
  150. syncableElement.version,
  151. );
  152. }
  153. const broadcastPromise = this._broadcastSocketData(
  154. data as SocketUpdateData,
  155. );
  156. this.queueFileUpload();
  157. if (syncAll && this.collab.isCollaborating) {
  158. await Promise.all([
  159. broadcastPromise,
  160. this.collab.saveCollabRoomToFirebase(syncableElements),
  161. ]);
  162. } else {
  163. await broadcastPromise;
  164. }
  165. };
  166. broadcastIdleChange = (userState: UserIdleState) => {
  167. if (this.socket?.id) {
  168. const data: SocketUpdateDataSource["IDLE_STATUS"] = {
  169. type: "IDLE_STATUS",
  170. payload: {
  171. socketId: this.socket.id,
  172. userState,
  173. username: this.collab.state.username,
  174. },
  175. };
  176. return this._broadcastSocketData(
  177. data as SocketUpdateData,
  178. true, // volatile
  179. );
  180. }
  181. };
  182. broadcastMouseLocation = (payload: {
  183. pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
  184. button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
  185. }) => {
  186. if (this.socket?.id) {
  187. const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
  188. type: "MOUSE_LOCATION",
  189. payload: {
  190. socketId: this.socket.id,
  191. pointer: payload.pointer,
  192. button: payload.button || "up",
  193. selectedElementIds: this.collab.excalidrawAPI.getAppState()
  194. .selectedElementIds,
  195. username: this.collab.state.username,
  196. },
  197. };
  198. return this._broadcastSocketData(
  199. data as SocketUpdateData,
  200. true, // volatile
  201. );
  202. }
  203. };
  204. }
  205. export default Portal;