2020-12-05 20:00:53 +05:30
|
|
|
import throttle from "lodash.throttle";
|
2021-10-14 22:56:51 +05:30
|
|
|
import { PureComponent } from "react";
|
2021-06-01 23:52:13 +05:30
|
|
|
import { ExcalidrawImperativeAPI } from "../../types";
|
2021-01-05 20:06:14 +02:00
|
|
|
import { ErrorDialog } from "../../components/ErrorDialog";
|
2020-12-22 11:34:06 +02:00
|
|
|
import { APP_NAME, ENV, EVENT } from "../../constants";
|
2021-01-05 20:06:14 +02:00
|
|
|
import { ImportedDataState } from "../../data/types";
|
2021-10-21 22:05:48 +02:00
|
|
|
import {
|
|
|
|
ExcalidrawElement,
|
|
|
|
InitializedExcalidrawImageElement,
|
|
|
|
} from "../../element/types";
|
2021-10-27 15:14:20 +02:00
|
|
|
import { getSceneVersion } from "../../packages/excalidraw/index";
|
2021-01-25 10:47:35 +01:00
|
|
|
import { Collaborator, Gesture } from "../../types";
|
2021-01-05 20:06:14 +02:00
|
|
|
import {
|
2021-10-21 22:05:48 +02:00
|
|
|
preventUnload,
|
|
|
|
resolvablePromise,
|
|
|
|
withBatchedUpdates,
|
|
|
|
} from "../../utils";
|
|
|
|
import {
|
|
|
|
FILE_UPLOAD_MAX_BYTES,
|
|
|
|
FIREBASE_STORAGE_PREFIXES,
|
2021-01-05 20:06:14 +02:00
|
|
|
INITIAL_SCENE_UPDATE_TIMEOUT,
|
2021-10-21 22:05:48 +02:00
|
|
|
LOAD_IMAGES_TIMEOUT,
|
2021-01-05 20:06:14 +02:00
|
|
|
SCENE,
|
|
|
|
SYNC_FULL_SCENE_INTERVAL_MS,
|
|
|
|
} from "../app_constants";
|
2020-12-05 20:00:53 +05:30
|
|
|
import {
|
|
|
|
decryptAESGEM,
|
2021-02-03 19:14:26 +01:00
|
|
|
generateCollaborationLinkData,
|
|
|
|
getCollaborationLink,
|
2021-01-05 20:06:14 +02:00
|
|
|
SocketUpdateDataSource,
|
2020-12-05 20:00:53 +05:30
|
|
|
SOCKET_SERVER,
|
|
|
|
} from "../data";
|
2021-02-03 19:14:26 +01:00
|
|
|
import {
|
|
|
|
isSavedToFirebase,
|
2021-10-21 22:05:48 +02:00
|
|
|
loadFilesFromFirebase,
|
2021-02-03 19:14:26 +01:00
|
|
|
loadFromFirebase,
|
2021-10-21 22:05:48 +02:00
|
|
|
saveFilesToFirebase,
|
2021-02-03 19:14:26 +01:00
|
|
|
saveToFirebase,
|
|
|
|
} from "../data/firebase";
|
2020-12-05 20:00:53 +05:30
|
|
|
import {
|
|
|
|
importUsernameFromLocalStorage,
|
|
|
|
saveUsernameToLocalStorage,
|
|
|
|
STORAGE_KEYS,
|
|
|
|
} from "../data/localStorage";
|
2021-01-05 20:06:14 +02:00
|
|
|
import Portal from "./Portal";
|
2020-12-05 20:00:53 +05:30
|
|
|
import RoomDialog from "./RoomDialog";
|
2021-01-25 10:47:35 +01:00
|
|
|
import { createInverseContext } from "../../createInverseContext";
|
2021-02-03 19:14:26 +01:00
|
|
|
import { t } from "../../i18n";
|
2021-03-28 19:26:03 +05:30
|
|
|
import { UserIdleState } from "../../types";
|
2021-02-04 11:55:43 +01:00
|
|
|
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
|
2021-03-10 17:45:37 +02:00
|
|
|
import { trackEvent } from "../../analytics";
|
2021-04-21 23:37:44 +02:00
|
|
|
import { isInvisiblySmallElement } from "../../element";
|
2021-10-21 22:05:48 +02:00
|
|
|
import {
|
|
|
|
encodeFilesForUpload,
|
|
|
|
FileManager,
|
|
|
|
updateStaleImageStatuses,
|
|
|
|
} from "../data/FileManager";
|
|
|
|
import { AbortError } from "../../errors";
|
|
|
|
import {
|
|
|
|
isImageElement,
|
|
|
|
isInitializedImageElement,
|
|
|
|
} from "../../element/typeChecks";
|
2021-10-30 23:40:35 +02:00
|
|
|
import { newElementWith } from "../../element/mutateElement";
|
2021-10-27 15:14:20 +02:00
|
|
|
import {
|
|
|
|
ReconciledElements,
|
|
|
|
reconcileElements as _reconcileElements,
|
|
|
|
} from "./reconciliation";
|
2020-12-05 20:00:53 +05:30
|
|
|
|
|
|
|
interface CollabState {
|
|
|
|
modalIsShown: boolean;
|
|
|
|
errorMessage: string;
|
|
|
|
username: string;
|
2021-02-04 11:55:43 +01:00
|
|
|
userState: UserIdleState;
|
2020-12-05 20:00:53 +05:30
|
|
|
activeRoomLink: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
type CollabInstance = InstanceType<typeof CollabWrapper>;
|
|
|
|
|
|
|
|
export interface CollabAPI {
|
2021-02-03 19:14:26 +01:00
|
|
|
/** function so that we can access the latest value from stale callbacks */
|
|
|
|
isCollaborating: () => boolean;
|
2020-12-05 20:00:53 +05:30
|
|
|
username: CollabState["username"];
|
2021-02-04 11:55:43 +01:00
|
|
|
userState: CollabState["userState"];
|
2020-12-05 20:00:53 +05:30
|
|
|
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
|
|
|
initializeSocketClient: CollabInstance["initializeSocketClient"];
|
|
|
|
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
|
|
|
|
broadcastElements: CollabInstance["broadcastElements"];
|
2021-10-21 22:05:48 +02:00
|
|
|
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
interface Props {
|
2021-01-25 10:47:35 +01:00
|
|
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
2021-10-21 22:05:48 +02:00
|
|
|
onRoomClose?: () => void;
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
|
2021-01-25 10:47:35 +01:00
|
|
|
const {
|
|
|
|
Context: CollabContext,
|
|
|
|
Consumer: CollabContextConsumer,
|
|
|
|
Provider: CollabContextProvider,
|
|
|
|
} = createInverseContext<{ api: CollabAPI | null }>({ api: null });
|
|
|
|
|
|
|
|
export { CollabContext, CollabContextConsumer };
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
class CollabWrapper extends PureComponent<Props, CollabState> {
|
|
|
|
portal: Portal;
|
2021-10-21 22:05:48 +02:00
|
|
|
fileManager: FileManager;
|
2021-01-25 10:47:35 +01:00
|
|
|
excalidrawAPI: Props["excalidrawAPI"];
|
2021-02-03 19:14:26 +01:00
|
|
|
isCollaborating: boolean = false;
|
2021-02-04 11:55:43 +01:00
|
|
|
activeIntervalId: number | null;
|
|
|
|
idleTimeoutId: number | null;
|
2021-02-03 19:14:26 +01:00
|
|
|
|
2021-10-21 22:05:48 +02:00
|
|
|
private socketInitializationTimer?: number;
|
2020-12-05 20:00:53 +05:30
|
|
|
private lastBroadcastedOrReceivedSceneVersion: number = -1;
|
|
|
|
private collaborators = new Map<string, Collaborator>();
|
|
|
|
|
|
|
|
constructor(props: Props) {
|
|
|
|
super(props);
|
|
|
|
this.state = {
|
|
|
|
modalIsShown: false,
|
|
|
|
errorMessage: "",
|
|
|
|
username: importUsernameFromLocalStorage() || "",
|
2021-02-04 11:55:43 +01:00
|
|
|
userState: UserIdleState.ACTIVE,
|
2020-12-05 20:00:53 +05:30
|
|
|
activeRoomLink: "",
|
|
|
|
};
|
|
|
|
this.portal = new Portal(this);
|
2021-10-21 22:05:48 +02:00
|
|
|
this.fileManager = new FileManager({
|
|
|
|
getFiles: async (fileIds) => {
|
|
|
|
const { roomId, roomKey } = this.portal;
|
|
|
|
if (!roomId || !roomKey) {
|
|
|
|
throw new AbortError();
|
|
|
|
}
|
|
|
|
|
|
|
|
return loadFilesFromFirebase(`files/rooms/${roomId}`, roomKey, fileIds);
|
|
|
|
},
|
|
|
|
saveFiles: async ({ addedFiles }) => {
|
|
|
|
const { roomId, roomKey } = this.portal;
|
|
|
|
if (!roomId || !roomKey) {
|
|
|
|
throw new AbortError();
|
|
|
|
}
|
|
|
|
|
|
|
|
return saveFilesToFirebase({
|
|
|
|
prefix: `${FIREBASE_STORAGE_PREFIXES.collabFiles}/${roomId}`,
|
|
|
|
files: await encodeFilesForUpload({
|
|
|
|
files: addedFiles,
|
|
|
|
encryptionKey: roomKey,
|
|
|
|
maxBytes: FILE_UPLOAD_MAX_BYTES,
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
},
|
|
|
|
});
|
2021-01-25 10:47:35 +01:00
|
|
|
this.excalidrawAPI = props.excalidrawAPI;
|
2021-02-04 11:55:43 +01:00
|
|
|
this.activeIntervalId = null;
|
|
|
|
this.idleTimeoutId = null;
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
componentDidMount() {
|
|
|
|
window.addEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
|
|
|
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
|
|
|
|
|
|
|
if (
|
|
|
|
process.env.NODE_ENV === ENV.TEST ||
|
|
|
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
|
|
|
) {
|
2021-03-28 19:26:03 +05:30
|
|
|
window.collab = window.collab || ({} as Window["collab"]);
|
|
|
|
Object.defineProperties(window, {
|
2020-12-05 20:00:53 +05:30
|
|
|
collab: {
|
|
|
|
configurable: true,
|
|
|
|
value: this,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
componentWillUnmount() {
|
|
|
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
|
|
|
|
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
|
2021-02-04 11:55:43 +01:00
|
|
|
window.removeEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
|
|
|
window.removeEventListener(
|
|
|
|
EVENT.VISIBILITY_CHANGE,
|
|
|
|
this.onVisibilityChange,
|
|
|
|
);
|
|
|
|
if (this.activeIntervalId) {
|
|
|
|
window.clearInterval(this.activeIntervalId);
|
|
|
|
this.activeIntervalId = null;
|
|
|
|
}
|
|
|
|
if (this.idleTimeoutId) {
|
|
|
|
window.clearTimeout(this.idleTimeoutId);
|
|
|
|
this.idleTimeoutId = null;
|
|
|
|
}
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
private onUnload = () => {
|
2021-02-03 19:14:26 +01:00
|
|
|
this.destroySocketClient({ isUnload: true });
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
|
|
|
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
|
2021-04-21 23:37:44 +02:00
|
|
|
const syncableElements = this.getSyncableElements(
|
2020-12-05 20:00:53 +05:30
|
|
|
this.getSceneElementsIncludingDeleted(),
|
|
|
|
);
|
2021-02-03 19:14:26 +01:00
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
if (
|
2021-02-03 19:14:26 +01:00
|
|
|
this.isCollaborating &&
|
2021-10-21 22:05:48 +02:00
|
|
|
(this.fileManager.shouldPreventUnload(syncableElements) ||
|
|
|
|
!isSavedToFirebase(this.portal, syncableElements))
|
2020-12-05 20:00:53 +05:30
|
|
|
) {
|
|
|
|
// this won't run in time if user decides to leave the site, but
|
|
|
|
// the purpose is to run in immediately after user decides to stay
|
|
|
|
this.saveCollabRoomToFirebase(syncableElements);
|
|
|
|
|
2021-10-21 22:05:48 +02:00
|
|
|
preventUnload(event);
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
if (this.isCollaborating || this.portal.roomId) {
|
2020-12-05 20:00:53 +05:30
|
|
|
try {
|
|
|
|
localStorage?.setItem(
|
|
|
|
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
|
|
|
|
JSON.stringify({
|
|
|
|
timestamp: Date.now(),
|
|
|
|
room: this.portal.roomId,
|
|
|
|
}),
|
|
|
|
);
|
|
|
|
} catch {}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
saveCollabRoomToFirebase = async (
|
2021-10-27 15:14:20 +02:00
|
|
|
syncableElements: readonly ExcalidrawElement[] = this.getSyncableElements(
|
2021-01-25 10:47:35 +01:00
|
|
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
2020-12-05 20:00:53 +05:30
|
|
|
),
|
|
|
|
) => {
|
|
|
|
try {
|
|
|
|
await saveToFirebase(this.portal, syncableElements);
|
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
openPortal = async () => {
|
2021-03-10 17:45:37 +02:00
|
|
|
trackEvent("share", "room creation");
|
2021-02-03 19:14:26 +01:00
|
|
|
return this.initializeSocketClient(null);
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
|
|
|
closePortal = () => {
|
2021-10-30 23:40:35 +02:00
|
|
|
this.queueBroadcastAllElements.cancel();
|
|
|
|
this.loadImageFiles.cancel();
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
this.saveCollabRoomToFirebase();
|
2021-02-03 19:14:26 +01:00
|
|
|
if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
|
|
|
|
window.history.pushState({}, APP_NAME, window.location.origin);
|
|
|
|
this.destroySocketClient();
|
2021-03-10 17:45:37 +02:00
|
|
|
trackEvent("share", "room closed");
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
this.props.onRoomClose?.();
|
|
|
|
|
|
|
|
const elements = this.excalidrawAPI
|
|
|
|
.getSceneElementsIncludingDeleted()
|
|
|
|
.map((element) => {
|
|
|
|
if (isImageElement(element) && element.status === "saved") {
|
2021-10-30 23:40:35 +02:00
|
|
|
return newElementWith(element, { status: "pending" });
|
2021-10-21 22:05:48 +02:00
|
|
|
}
|
|
|
|
return element;
|
|
|
|
});
|
|
|
|
|
|
|
|
this.excalidrawAPI.updateScene({
|
|
|
|
elements,
|
|
|
|
commitToHistory: false,
|
|
|
|
});
|
2021-02-03 19:14:26 +01:00
|
|
|
}
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
private destroySocketClient = (opts?: { isUnload: boolean }) => {
|
|
|
|
if (!opts?.isUnload) {
|
|
|
|
this.collaborators = new Map();
|
|
|
|
this.excalidrawAPI.updateScene({
|
|
|
|
collaborators: this.collaborators,
|
|
|
|
});
|
|
|
|
this.setState({
|
|
|
|
activeRoomLink: "",
|
|
|
|
});
|
|
|
|
this.isCollaborating = false;
|
|
|
|
}
|
2021-10-21 22:05:48 +02:00
|
|
|
this.lastBroadcastedOrReceivedSceneVersion = -1;
|
2020-12-05 20:00:53 +05:30
|
|
|
this.portal.close();
|
2021-10-21 22:05:48 +02:00
|
|
|
this.fileManager.reset();
|
|
|
|
};
|
|
|
|
|
|
|
|
private fetchImageFilesFromFirebase = async (scene: {
|
|
|
|
elements: readonly ExcalidrawElement[];
|
|
|
|
}) => {
|
|
|
|
const unfetchedImages = scene.elements
|
|
|
|
.filter((element) => {
|
|
|
|
return (
|
|
|
|
isInitializedImageElement(element) &&
|
|
|
|
!this.fileManager.isFileHandled(element.fileId) &&
|
|
|
|
!element.isDeleted &&
|
|
|
|
element.status === "saved"
|
|
|
|
);
|
|
|
|
})
|
|
|
|
.map((element) => (element as InitializedExcalidrawImageElement).fileId);
|
|
|
|
|
|
|
|
return await this.fileManager.getFiles(unfetchedImages);
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
private initializeSocketClient = async (
|
|
|
|
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
|
|
|
): Promise<ImportedDataState | null> => {
|
2020-12-05 20:00:53 +05:30
|
|
|
if (this.portal.socket) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
let roomId;
|
|
|
|
let roomKey;
|
|
|
|
|
|
|
|
if (existingRoomLinkData) {
|
|
|
|
({ roomId, roomKey } = existingRoomLinkData);
|
|
|
|
} else {
|
|
|
|
({ roomId, roomKey } = await generateCollaborationLinkData());
|
|
|
|
window.history.pushState(
|
|
|
|
{},
|
|
|
|
APP_NAME,
|
|
|
|
getCollaborationLink({ roomId, roomKey }),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
const scenePromise = resolvablePromise<ImportedDataState | null>();
|
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
this.isCollaborating = true;
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
const { default: socketIOClient }: any = await import(
|
|
|
|
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
|
|
|
|
);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
if (existingRoomLinkData) {
|
|
|
|
this.excalidrawAPI.resetScene();
|
|
|
|
|
|
|
|
try {
|
|
|
|
const elements = await loadFromFirebase(
|
|
|
|
roomId,
|
|
|
|
roomKey,
|
|
|
|
this.portal.socket,
|
|
|
|
);
|
|
|
|
if (elements) {
|
|
|
|
scenePromise.resolve({
|
|
|
|
elements,
|
2021-03-16 23:02:17 +05:30
|
|
|
scrollToContent: true,
|
2021-02-03 19:14:26 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
// log the error and move on. other peers will sync us the scene.
|
|
|
|
console.error(error);
|
|
|
|
}
|
|
|
|
} else {
|
2021-10-21 22:05:48 +02:00
|
|
|
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
|
|
|
if (isImageElement(element) && element.status === "saved") {
|
2021-10-30 23:40:35 +02:00
|
|
|
return newElementWith(element, { status: "pending" });
|
2021-10-21 22:05:48 +02:00
|
|
|
}
|
|
|
|
return element;
|
|
|
|
});
|
2021-02-03 19:14:26 +01:00
|
|
|
// remove deleted elements from elements array & history to ensure we don't
|
|
|
|
// expose potentially sensitive user data in case user manually deletes
|
|
|
|
// existing elements (or clears scene), which would otherwise be persisted
|
|
|
|
// to database even if deleted before creating the room.
|
|
|
|
this.excalidrawAPI.history.clear();
|
|
|
|
this.excalidrawAPI.updateScene({
|
|
|
|
elements,
|
|
|
|
commitToHistory: true,
|
|
|
|
});
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
this.broadcastElements(elements);
|
|
|
|
|
|
|
|
const syncableElements = this.getSyncableElements(elements);
|
|
|
|
this.saveCollabRoomToFirebase(syncableElements);
|
2021-02-03 19:14:26 +01:00
|
|
|
}
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
// fallback in case you're not alone in the room but still don't receive
|
|
|
|
// initial SCENE_UPDATE message
|
2021-10-21 22:05:48 +02:00
|
|
|
this.socketInitializationTimer = window.setTimeout(() => {
|
2021-02-03 19:14:26 +01:00
|
|
|
this.initializeSocket();
|
|
|
|
scenePromise.resolve(null);
|
|
|
|
}, INITIAL_SCENE_UPDATE_TIMEOUT);
|
|
|
|
|
|
|
|
// All socket listeners are moving to Portal
|
|
|
|
this.portal.socket!.on(
|
|
|
|
"client-broadcast",
|
|
|
|
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
|
|
|
|
if (!this.portal.roomKey) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const decryptedData = await decryptAESGEM(
|
|
|
|
encryptedData,
|
|
|
|
this.portal.roomKey,
|
|
|
|
iv,
|
|
|
|
);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
switch (decryptedData.type) {
|
|
|
|
case "INVALID_RESPONSE":
|
2020-12-05 20:00:53 +05:30
|
|
|
return;
|
2021-02-03 19:14:26 +01:00
|
|
|
case SCENE.INIT: {
|
|
|
|
if (!this.portal.socketInitialized) {
|
|
|
|
this.initializeSocket();
|
|
|
|
const remoteElements = decryptedData.payload.elements;
|
|
|
|
const reconciledElements = this.reconcileElements(remoteElements);
|
|
|
|
this.handleRemoteSceneUpdate(reconciledElements, {
|
|
|
|
init: true,
|
2020-12-05 20:00:53 +05:30
|
|
|
});
|
2021-02-03 19:14:26 +01:00
|
|
|
// noop if already resolved via init from firebase
|
2021-02-21 19:01:34 +05:30
|
|
|
scenePromise.resolve({
|
|
|
|
elements: reconciledElements,
|
2021-03-16 23:02:17 +05:30
|
|
|
scrollToContent: true,
|
2021-02-21 19:01:34 +05:30
|
|
|
});
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
2021-02-03 19:14:26 +01:00
|
|
|
break;
|
|
|
|
}
|
|
|
|
case SCENE.UPDATE:
|
|
|
|
this.handleRemoteSceneUpdate(
|
|
|
|
this.reconcileElements(decryptedData.payload.elements),
|
|
|
|
);
|
|
|
|
break;
|
|
|
|
case "MOUSE_LOCATION": {
|
2021-11-01 15:24:05 +02:00
|
|
|
const { pointer, button, username, selectedElementIds } =
|
|
|
|
decryptedData.payload;
|
2021-02-03 19:14:26 +01:00
|
|
|
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
|
|
|
|
decryptedData.payload.socketId ||
|
|
|
|
// @ts-ignore legacy, see #2094 (#2097)
|
|
|
|
decryptedData.payload.socketID;
|
|
|
|
|
|
|
|
const collaborators = new Map(this.collaborators);
|
|
|
|
const user = collaborators.get(socketId) || {}!;
|
|
|
|
user.pointer = pointer;
|
|
|
|
user.button = button;
|
|
|
|
user.selectedElementIds = selectedElementIds;
|
|
|
|
user.username = username;
|
|
|
|
collaborators.set(socketId, user);
|
|
|
|
this.excalidrawAPI.updateScene({
|
|
|
|
collaborators,
|
|
|
|
});
|
|
|
|
break;
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
2021-02-04 11:55:43 +01:00
|
|
|
case "IDLE_STATUS": {
|
|
|
|
const { userState, socketId, username } = decryptedData.payload;
|
|
|
|
const collaborators = new Map(this.collaborators);
|
|
|
|
const user = collaborators.get(socketId) || {}!;
|
|
|
|
user.userState = userState;
|
|
|
|
user.username = username;
|
|
|
|
this.excalidrawAPI.updateScene({
|
|
|
|
collaborators,
|
|
|
|
});
|
|
|
|
break;
|
|
|
|
}
|
2020-12-05 20:00:53 +05:30
|
|
|
}
|
2021-02-03 19:14:26 +01:00
|
|
|
},
|
|
|
|
);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
this.portal.socket!.on("first-in-room", () => {
|
|
|
|
if (this.portal.socket) {
|
|
|
|
this.portal.socket.off("first-in-room");
|
|
|
|
}
|
|
|
|
this.initializeSocket();
|
|
|
|
scenePromise.resolve(null);
|
|
|
|
});
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-04 11:55:43 +01:00
|
|
|
this.initializeIdleDetector();
|
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
this.setState({
|
|
|
|
activeRoomLink: window.location.href,
|
|
|
|
});
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
return scenePromise;
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
|
|
|
private initializeSocket = () => {
|
|
|
|
this.portal.socketInitialized = true;
|
|
|
|
clearTimeout(this.socketInitializationTimer!);
|
|
|
|
};
|
|
|
|
|
|
|
|
private reconcileElements = (
|
2021-10-27 15:14:20 +02:00
|
|
|
remoteElements: readonly ExcalidrawElement[],
|
2020-12-05 20:00:53 +05:30
|
|
|
): ReconciledElements => {
|
2021-10-27 15:14:20 +02:00
|
|
|
const localElements = this.getSceneElementsIncludingDeleted();
|
2021-01-25 10:47:35 +01:00
|
|
|
const appState = this.excalidrawAPI.getAppState();
|
|
|
|
|
2021-10-27 15:14:20 +02:00
|
|
|
const reconciledElements = _reconcileElements(
|
|
|
|
localElements,
|
|
|
|
remoteElements,
|
|
|
|
appState,
|
|
|
|
);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
|
|
|
// Avoid broadcasting to the rest of the collaborators the scene
|
|
|
|
// we just received!
|
|
|
|
// Note: this needs to be set before updating the scene as it
|
2021-02-04 11:55:43 +01:00
|
|
|
// synchronously calls render.
|
2021-10-27 15:14:20 +02:00
|
|
|
this.setLastBroadcastedOrReceivedSceneVersion(
|
|
|
|
getSceneVersion(reconciledElements),
|
|
|
|
);
|
2020-12-05 20:00:53 +05:30
|
|
|
|
2021-10-27 15:14:20 +02:00
|
|
|
return reconciledElements;
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
2021-10-21 22:05:48 +02:00
|
|
|
private loadImageFiles = throttle(async () => {
|
2021-11-01 15:24:05 +02:00
|
|
|
const { loadedFiles, erroredFiles } =
|
|
|
|
await this.fetchImageFilesFromFirebase({
|
|
|
|
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
|
|
});
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
this.excalidrawAPI.addFiles(loadedFiles);
|
|
|
|
|
|
|
|
updateStaleImageStatuses({
|
|
|
|
excalidrawAPI: this.excalidrawAPI,
|
|
|
|
erroredFiles,
|
|
|
|
elements: this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
|
|
});
|
|
|
|
}, LOAD_IMAGES_TIMEOUT);
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
private handleRemoteSceneUpdate = (
|
|
|
|
elements: ReconciledElements,
|
2021-03-21 09:25:19 -07:00
|
|
|
{ init = false }: { init?: boolean } = {},
|
2020-12-05 20:00:53 +05:30
|
|
|
) => {
|
2021-01-25 10:47:35 +01:00
|
|
|
this.excalidrawAPI.updateScene({
|
2020-12-05 20:00:53 +05:30
|
|
|
elements,
|
|
|
|
commitToHistory: !!init,
|
|
|
|
});
|
|
|
|
|
|
|
|
// We haven't yet implemented multiplayer undo functionality, so we clear the undo stack
|
|
|
|
// when we receive any messages from another peer. This UX can be pretty rough -- if you
|
|
|
|
// undo, a user makes a change, and then try to redo, your element(s) will be lost. However,
|
|
|
|
// right now we think this is the right tradeoff.
|
2021-01-25 10:47:35 +01:00
|
|
|
this.excalidrawAPI.history.clear();
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
this.loadImageFiles();
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
2021-02-04 11:55:43 +01:00
|
|
|
private onPointerMove = () => {
|
|
|
|
if (this.idleTimeoutId) {
|
|
|
|
window.clearTimeout(this.idleTimeoutId);
|
|
|
|
this.idleTimeoutId = null;
|
|
|
|
}
|
|
|
|
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
|
|
|
if (!this.activeIntervalId) {
|
|
|
|
this.activeIntervalId = window.setInterval(
|
|
|
|
this.reportActive,
|
|
|
|
ACTIVE_THRESHOLD,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
private onVisibilityChange = () => {
|
|
|
|
if (document.hidden) {
|
|
|
|
if (this.idleTimeoutId) {
|
|
|
|
window.clearTimeout(this.idleTimeoutId);
|
|
|
|
this.idleTimeoutId = null;
|
|
|
|
}
|
|
|
|
if (this.activeIntervalId) {
|
|
|
|
window.clearInterval(this.activeIntervalId);
|
|
|
|
this.activeIntervalId = null;
|
|
|
|
}
|
|
|
|
this.onIdleStateChange(UserIdleState.AWAY);
|
|
|
|
} else {
|
|
|
|
this.idleTimeoutId = window.setTimeout(this.reportIdle, IDLE_THRESHOLD);
|
|
|
|
this.activeIntervalId = window.setInterval(
|
|
|
|
this.reportActive,
|
|
|
|
ACTIVE_THRESHOLD,
|
|
|
|
);
|
|
|
|
this.onIdleStateChange(UserIdleState.ACTIVE);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
private reportIdle = () => {
|
|
|
|
this.onIdleStateChange(UserIdleState.IDLE);
|
|
|
|
if (this.activeIntervalId) {
|
|
|
|
window.clearInterval(this.activeIntervalId);
|
|
|
|
this.activeIntervalId = null;
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
private reportActive = () => {
|
|
|
|
this.onIdleStateChange(UserIdleState.ACTIVE);
|
|
|
|
};
|
|
|
|
|
|
|
|
private initializeIdleDetector = () => {
|
|
|
|
document.addEventListener(EVENT.POINTER_MOVE, this.onPointerMove);
|
|
|
|
document.addEventListener(EVENT.VISIBILITY_CHANGE, this.onVisibilityChange);
|
|
|
|
};
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
setCollaborators(sockets: string[]) {
|
|
|
|
this.setState((state) => {
|
2021-11-01 15:24:05 +02:00
|
|
|
const collaborators: InstanceType<typeof CollabWrapper>["collaborators"] =
|
|
|
|
new Map();
|
2020-12-05 20:00:53 +05:30
|
|
|
for (const socketId of sockets) {
|
|
|
|
if (this.collaborators.has(socketId)) {
|
|
|
|
collaborators.set(socketId, this.collaborators.get(socketId)!);
|
|
|
|
} else {
|
|
|
|
collaborators.set(socketId, {});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.collaborators = collaborators;
|
2021-01-25 10:47:35 +01:00
|
|
|
this.excalidrawAPI.updateScene({ collaborators });
|
2020-12-05 20:00:53 +05:30
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
|
|
|
this.lastBroadcastedOrReceivedSceneVersion = version;
|
|
|
|
};
|
|
|
|
|
|
|
|
public getLastBroadcastedOrReceivedSceneVersion = () => {
|
|
|
|
return this.lastBroadcastedOrReceivedSceneVersion;
|
|
|
|
};
|
|
|
|
|
|
|
|
public getSceneElementsIncludingDeleted = () => {
|
2021-01-25 10:47:35 +01:00
|
|
|
return this.excalidrawAPI.getSceneElementsIncludingDeleted();
|
2020-12-05 20:00:53 +05:30
|
|
|
};
|
|
|
|
|
|
|
|
onPointerUpdate = (payload: {
|
|
|
|
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
|
|
|
|
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
|
|
|
|
pointersMap: Gesture["pointers"];
|
|
|
|
}) => {
|
|
|
|
payload.pointersMap.size < 2 &&
|
|
|
|
this.portal.socket &&
|
|
|
|
this.portal.broadcastMouseLocation(payload);
|
|
|
|
};
|
|
|
|
|
2021-02-04 11:55:43 +01:00
|
|
|
onIdleStateChange = (userState: UserIdleState) => {
|
|
|
|
this.setState({ userState });
|
|
|
|
this.portal.broadcastIdleChange(userState);
|
|
|
|
};
|
|
|
|
|
2021-01-25 10:47:35 +01:00
|
|
|
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
|
2020-12-05 20:00:53 +05:30
|
|
|
if (
|
|
|
|
getSceneVersion(elements) >
|
|
|
|
this.getLastBroadcastedOrReceivedSceneVersion()
|
|
|
|
) {
|
2021-10-27 15:14:20 +02:00
|
|
|
this.portal.broadcastScene(SCENE.UPDATE, elements, false);
|
2020-12-05 20:00:53 +05:30
|
|
|
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
|
|
|
|
this.queueBroadcastAllElements();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
queueBroadcastAllElements = throttle(() => {
|
|
|
|
this.portal.broadcastScene(
|
|
|
|
SCENE.UPDATE,
|
2021-10-27 15:14:20 +02:00
|
|
|
this.excalidrawAPI.getSceneElementsIncludingDeleted(),
|
2020-12-05 20:00:53 +05:30
|
|
|
true,
|
|
|
|
);
|
|
|
|
const currentVersion = this.getLastBroadcastedOrReceivedSceneVersion();
|
|
|
|
const newVersion = Math.max(
|
|
|
|
currentVersion,
|
|
|
|
getSceneVersion(this.getSceneElementsIncludingDeleted()),
|
|
|
|
);
|
|
|
|
this.setLastBroadcastedOrReceivedSceneVersion(newVersion);
|
|
|
|
}, SYNC_FULL_SCENE_INTERVAL_MS);
|
|
|
|
|
|
|
|
handleClose = () => {
|
|
|
|
this.setState({ modalIsShown: false });
|
|
|
|
};
|
|
|
|
|
|
|
|
onUsernameChange = (username: string) => {
|
|
|
|
this.setState({ username });
|
|
|
|
saveUsernameToLocalStorage(username);
|
|
|
|
};
|
|
|
|
|
|
|
|
onCollabButtonClick = () => {
|
|
|
|
this.setState({
|
|
|
|
modalIsShown: true,
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2021-10-27 15:14:20 +02:00
|
|
|
isSyncableElement = (element: ExcalidrawElement) => {
|
|
|
|
return element.isDeleted || !isInvisiblySmallElement(element);
|
|
|
|
};
|
|
|
|
|
2021-04-21 23:37:44 +02:00
|
|
|
getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
2021-10-27 15:14:20 +02:00
|
|
|
elements.filter((element) => this.isSyncableElement(element));
|
2021-04-21 23:37:44 +02:00
|
|
|
|
2021-01-25 10:47:35 +01:00
|
|
|
/** PRIVATE. Use `this.getContextValue()` instead. */
|
|
|
|
private contextValue: CollabAPI | null = null;
|
|
|
|
|
|
|
|
/** Getter of context value. Returned object is stable. */
|
|
|
|
getContextValue = (): CollabAPI => {
|
2021-02-03 19:14:26 +01:00
|
|
|
if (!this.contextValue) {
|
|
|
|
this.contextValue = {} as CollabAPI;
|
|
|
|
}
|
2021-01-25 10:47:35 +01:00
|
|
|
|
2021-02-03 19:14:26 +01:00
|
|
|
this.contextValue.isCollaborating = () => this.isCollaborating;
|
2021-01-25 10:47:35 +01:00
|
|
|
this.contextValue.username = this.state.username;
|
|
|
|
this.contextValue.onPointerUpdate = this.onPointerUpdate;
|
|
|
|
this.contextValue.initializeSocketClient = this.initializeSocketClient;
|
|
|
|
this.contextValue.onCollabButtonClick = this.onCollabButtonClick;
|
|
|
|
this.contextValue.broadcastElements = this.broadcastElements;
|
2021-11-01 15:24:05 +02:00
|
|
|
this.contextValue.fetchImageFilesFromFirebase =
|
|
|
|
this.fetchImageFilesFromFirebase;
|
2021-01-25 10:47:35 +01:00
|
|
|
return this.contextValue;
|
|
|
|
};
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
render() {
|
|
|
|
const { modalIsShown, username, errorMessage, activeRoomLink } = this.state;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<>
|
|
|
|
{modalIsShown && (
|
|
|
|
<RoomDialog
|
|
|
|
handleClose={this.handleClose}
|
|
|
|
activeRoomLink={activeRoomLink}
|
|
|
|
username={username}
|
|
|
|
onUsernameChange={this.onUsernameChange}
|
|
|
|
onRoomCreate={this.openPortal}
|
|
|
|
onRoomDestroy={this.closePortal}
|
|
|
|
setErrorMessage={(errorMessage) => {
|
|
|
|
this.setState({ errorMessage });
|
|
|
|
}}
|
2021-04-13 23:02:57 +05:30
|
|
|
theme={this.excalidrawAPI.getAppState().theme}
|
2020-12-05 20:00:53 +05:30
|
|
|
/>
|
|
|
|
)}
|
|
|
|
{errorMessage && (
|
|
|
|
<ErrorDialog
|
|
|
|
message={errorMessage}
|
|
|
|
onClose={() => this.setState({ errorMessage: "" })}
|
|
|
|
/>
|
|
|
|
)}
|
2021-01-25 10:47:35 +01:00
|
|
|
<CollabContextProvider
|
|
|
|
value={{
|
|
|
|
api: this.getContextValue(),
|
|
|
|
}}
|
|
|
|
/>
|
2020-12-05 20:00:53 +05:30
|
|
|
</>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-03-28 19:26:03 +05:30
|
|
|
declare global {
|
|
|
|
interface Window {
|
|
|
|
collab: InstanceType<typeof CollabWrapper>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (
|
|
|
|
process.env.NODE_ENV === ENV.TEST ||
|
|
|
|
process.env.NODE_ENV === ENV.DEVELOPMENT
|
|
|
|
) {
|
|
|
|
window.collab = window.collab || ({} as Window["collab"]);
|
|
|
|
}
|
|
|
|
|
2020-12-05 20:00:53 +05:30
|
|
|
export default CollabWrapper;
|