Portal.tsx 6.4 KB

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