Factor out collaboration code (#2313)

Co-authored-by: Lipis <lipiridis@gmail.com>
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Aakansha Doshi 2020-12-05 20:00:53 +05:30 committed by GitHub
parent d8a0dc3b4d
commit e617ccc252
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2250 additions and 2018 deletions

View file

@ -0,0 +1,476 @@
import React, { PureComponent } from "react";
import throttle from "lodash.throttle";
import { ENV, EVENT } from "../../constants";
import {
decryptAESGEM,
SocketUpdateDataSource,
getCollaborationLinkData,
generateCollaborationLink,
SOCKET_SERVER,
} from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase";
import Portal from "./Portal";
import { AppState, Collaborator, Gesture } from "../../types";
import { ExcalidrawElement } from "../../element/types";
import {
importUsernameFromLocalStorage,
saveUsernameToLocalStorage,
STORAGE_KEYS,
} from "../data/localStorage";
import { resolvablePromise, withBatchedUpdates } from "../../utils";
import {
getSceneVersion,
getSyncableElements,
} from "../../packages/excalidraw/index";
import RoomDialog from "./RoomDialog";
import { ErrorDialog } from "../../components/ErrorDialog";
import { ImportedDataState } from "../../data/types";
import { ExcalidrawImperativeAPI } from "../../components/App";
import {
INITIAL_SCENE_UPDATE_TIMEOUT,
SCENE,
SYNC_FULL_SCENE_INTERVAL_MS,
} from "../app_constants";
import { EVENT_SHARE, trackEvent } from "../../analytics";
interface CollabState {
isCollaborating: boolean;
modalIsShown: boolean;
errorMessage: string;
username: string;
activeRoomLink: string;
}
type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI {
isCollaborating: CollabState["isCollaborating"];
username: CollabState["username"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
broadcastElements: CollabInstance["broadcastElements"];
}
type ReconciledElements = readonly ExcalidrawElement[] & {
_brand: "reconciledElements";
};
interface Props {
children: (collab: CollabAPI) => React.ReactNode;
// NOTE not type-safe because the refObject may in fact not be initialized
// with ExcalidrawImperativeAPI yet
excalidrawRef: React.MutableRefObject<ExcalidrawImperativeAPI>;
}
class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
private socketInitializationTimer?: NodeJS.Timeout;
private excalidrawRef: Props["excalidrawRef"];
excalidrawAppState?: AppState;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>();
constructor(props: Props) {
super(props);
this.state = {
isCollaborating: false,
modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
activeRoomLink: "",
};
this.portal = new Portal(this);
this.excalidrawRef = props.excalidrawRef;
}
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
) {
window.h = window.h || ({} as Window["h"]);
Object.defineProperties(window.h, {
collab: {
configurable: true,
value: this,
},
});
}
}
componentWillUnmount() {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
}
private onUnload = () => {
this.destroySocketClient();
};
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(),
);
if (
this.state.isCollaborating &&
!isSavedToFirebase(this.portal, syncableElements)
) {
// 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);
event.preventDefault();
// NOTE: modern browsers no longer allow showing a custom message here
event.returnValue = "";
}
if (this.state.isCollaborating || this.portal.roomId) {
try {
localStorage?.setItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
JSON.stringify({
timestamp: Date.now(),
room: this.portal.roomId,
}),
);
} catch {}
}
});
saveCollabRoomToFirebase = async (
syncableElements: ExcalidrawElement[] = getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
),
) => {
try {
await saveToFirebase(this.portal, syncableElements);
} catch (error) {
console.error(error);
}
};
openPortal = async () => {
window.history.pushState(
{},
"Excalidraw",
await generateCollaborationLink(),
);
const elements = this.excalidrawRef.current!.getSceneElements();
// 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.excalidrawRef.current!.history.clear();
this.excalidrawRef.current!.updateScene({
elements,
commitToHistory: true,
});
trackEvent(EVENT_SHARE, "session start");
return this.initializeSocketClient();
};
closePortal = () => {
this.saveCollabRoomToFirebase();
window.history.pushState({}, "Excalidraw", window.location.origin);
this.destroySocketClient();
trackEvent(EVENT_SHARE, "session end");
};
private destroySocketClient = () => {
this.collaborators = new Map();
this.excalidrawRef.current!.updateScene({
collaborators: this.collaborators,
});
this.setState({
isCollaborating: false,
activeRoomLink: "",
});
this.portal.close();
};
private initializeSocketClient = async (): Promise<ImportedDataState | null> => {
if (this.portal.socket) {
return null;
}
const scenePromise = resolvablePromise<ImportedDataState | null>();
const roomMatch = getCollaborationLinkData(window.location.href);
if (roomMatch) {
const roomId = roomMatch[1];
const roomKey = roomMatch[2];
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
);
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
// 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,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(
remoteElements,
);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.initializeSocket();
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
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.excalidrawRef.current!.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.setState({
isCollaborating: true,
activeRoomLink: window.location.href,
});
return scenePromise;
}
return null;
};
private initializeSocket = () => {
this.portal.socketInitialized = true;
clearTimeout(this.socketInitializationTimer!);
};
private reconcileElements = (
elements: readonly ExcalidrawElement[],
): ReconciledElements => {
const newElements = this.portal.reconcileElements(elements);
// 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
// syncronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
return newElements as ReconciledElements;
};
private handleRemoteSceneUpdate = (
elements: ReconciledElements,
{
init = false,
initFromSnapshot = false,
}: { init?: boolean; initFromSnapshot?: boolean } = {},
) => {
if (init || initFromSnapshot) {
this.excalidrawRef.current!.setScrollToCenter(elements);
}
this.excalidrawRef.current!.updateScene({
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.
this.excalidrawRef.current!.history.clear();
};
setCollaborators(sockets: string[]) {
this.setState((state) => {
const collaborators: InstanceType<
typeof CollabWrapper
>["collaborators"] = new Map();
for (const socketId of sockets) {
if (this.collaborators.has(socketId)) {
collaborators.set(socketId, this.collaborators.get(socketId)!);
} else {
collaborators.set(socketId, {});
}
}
this.collaborators = collaborators;
this.excalidrawRef.current!.updateScene({ collaborators });
});
}
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
this.lastBroadcastedOrReceivedSceneVersion = version;
};
public getLastBroadcastedOrReceivedSceneVersion = () => {
return this.lastBroadcastedOrReceivedSceneVersion;
};
public getSceneElementsIncludingDeleted = () => {
return this.excalidrawRef.current!.getSceneElementsIncludingDeleted();
};
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);
};
broadcastElements = (
elements: readonly ExcalidrawElement[],
state: AppState,
) => {
this.excalidrawAppState = state;
if (
getSceneVersion(elements) >
this.getLastBroadcastedOrReceivedSceneVersion()
) {
this.portal.broadcastScene(
SCENE.UPDATE,
getSyncableElements(elements),
false,
);
this.lastBroadcastedOrReceivedSceneVersion = getSceneVersion(elements);
this.queueBroadcastAllElements();
}
};
queueBroadcastAllElements = throttle(() => {
this.portal.broadcastScene(
SCENE.UPDATE,
getSyncableElements(
this.excalidrawRef.current!.getSceneElementsIncludingDeleted(),
),
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 });
const collabIcon = document.querySelector(".CollabButton") as HTMLElement;
collabIcon.focus();
};
onUsernameChange = (username: string) => {
this.setState({ username });
saveUsernameToLocalStorage(username);
};
onCollabButtonClick = () => {
this.setState({
modalIsShown: true,
});
};
render() {
const { children } = this.props;
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 });
}}
/>
)}
{errorMessage && (
<ErrorDialog
message={errorMessage}
onClose={() => this.setState({ errorMessage: "" })}
/>
)}
{children({
isCollaborating: this.state.isCollaborating,
username: this.state.username,
onPointerUpdate: this.onPointerUpdate,
initializeSocketClient: this.initializeSocketClient,
onCollabButtonClick: this.onCollabButtonClick,
broadcastElements: this.broadcastElements,
})}
</>
);
}
}
export default CollabWrapper;

View file

@ -0,0 +1,218 @@
import {
encryptAESGEM,
SocketUpdateData,
SocketUpdateDataSource,
} from "../data";
import CollabWrapper from "./CollabWrapper";
import {
getElementMap,
getSyncableElements,
} from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
class Portal {
app: CollabWrapper;
socket: SocketIOClient.Socket | null = null;
socketInitialized: boolean = false; // we don't want the socket to emit any updates until it is fully initialized
roomId: string | null = null;
roomKey: string | null = null;
broadcastedElementVersions: Map<string, number> = new Map();
constructor(app: CollabWrapper) {
this.app = app;
}
open(socket: SocketIOClient.Socket, id: string, key: string) {
this.socket = socket;
this.roomId = id;
this.roomKey = key;
// Initialize socket listeners (moving from App)
this.socket.on("init-room", () => {
if (this.socket) {
this.socket.emit("join-room", this.roomId);
}
});
this.socket.on("new-user", async (_socketId: string) => {
this.broadcastScene(
SCENE.INIT,
getSyncableElements(this.app.getSceneElementsIncludingDeleted()),
/* syncAll */ true,
);
});
this.socket.on("room-user-change", (clients: string[]) => {
this.app.setCollaborators(clients);
});
}
close() {
if (!this.socket) {
return;
}
this.socket.close();
this.socket = null;
this.roomId = null;
this.roomKey = null;
this.socketInitialized = false;
this.broadcastedElementVersions = new Map();
}
isOpen() {
return !!(
this.socketInitialized &&
this.socket &&
this.roomId &&
this.roomKey
);
}
async _broadcastSocketData(
data: SocketUpdateData,
volatile: boolean = false,
) {
if (this.isOpen()) {
const json = JSON.stringify(data);
const encoded = new TextEncoder().encode(json);
const encrypted = await encryptAESGEM(encoded, this.roomKey!);
this.socket!.emit(
volatile ? BROADCAST.SERVER_VOLATILE : BROADCAST.SERVER,
this.roomId,
encrypted.data,
encrypted.iv,
);
}
}
broadcastScene = async (
sceneType: SCENE.INIT | SCENE.UPDATE,
syncableElements: ExcalidrawElement[],
syncAll: boolean,
) => {
if (sceneType === SCENE.INIT && !syncAll) {
throw new Error("syncAll must be true when sending SCENE.INIT");
}
if (!syncAll) {
// sync out only the elements we think we need to to save bandwidth.
// periodically we'll resync the whole thing to make sure no one diverges
// due to a dropped message (server goes down etc).
syncableElements = syncableElements.filter(
(syncableElement) =>
!this.broadcastedElementVersions.has(syncableElement.id) ||
syncableElement.version >
this.broadcastedElementVersions.get(syncableElement.id)!,
);
}
const data: SocketUpdateDataSource[typeof sceneType] = {
type: sceneType,
payload: {
elements: syncableElements,
},
};
for (const syncableElement of syncableElements) {
this.broadcastedElementVersions.set(
syncableElement.id,
syncableElement.version,
);
}
const broadcastPromise = this._broadcastSocketData(
data as SocketUpdateData,
);
if (syncAll && this.app.state.isCollaborating) {
await Promise.all([
broadcastPromise,
this.app.saveCollabRoomToFirebase(syncableElements),
]);
} else {
await broadcastPromise;
}
};
broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
}) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["MOUSE_LOCATION"] = {
type: "MOUSE_LOCATION",
payload: {
socketId: this.socket.id,
pointer: payload.pointer,
button: payload.button || "up",
selectedElementIds:
this.app.excalidrawAppState?.selectedElementIds || {},
username: this.app.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};
reconcileElements = (
sceneElements: readonly ExcalidrawElement[],
): readonly ExcalidrawElement[] => {
const currentElements = this.app.getSceneElementsIncludingDeleted();
// create a map of ids so we don't have to iterate
// over the array more than once.
const localElementMap = getElementMap(currentElements);
// Reconcile
return (
sceneElements
.reduce((elements, element) => {
// if the remote element references one that's currently
// edited on local, skip it (it'll be added in the next step)
if (
element.id === this.app.excalidrawAppState?.editingElement?.id ||
element.id === this.app.excalidrawAppState?.resizingElement?.id ||
element.id === this.app.excalidrawAppState?.draggingElement?.id
) {
return elements;
}
if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version > element.version
) {
elements.push(localElementMap[element.id]);
delete localElementMap[element.id];
} else if (
localElementMap.hasOwnProperty(element.id) &&
localElementMap[element.id].version === element.version &&
localElementMap[element.id].versionNonce !== element.versionNonce
) {
// resolve conflicting edits deterministically by taking the one with the lowest versionNonce
if (
localElementMap[element.id].versionNonce < element.versionNonce
) {
elements.push(localElementMap[element.id]);
} else {
// it should be highly unlikely that the two versionNonces are the same. if we are
// really worried about this, we can replace the versionNonce with the socket id.
elements.push(element);
}
delete localElementMap[element.id];
} else {
elements.push(element);
delete localElementMap[element.id];
}
return elements;
}, [] as Mutable<typeof sceneElements>)
// add local elements that weren't deleted or on remote
.concat(...Object.values(localElementMap))
);
};
}
export default Portal;

View file

@ -0,0 +1,63 @@
@import "../../css/_variables";
.excalidraw {
.RoomDialog-linkContainer {
display: flex;
margin: 1.5em 0;
}
.RoomDialog-link {
color: var(--text-color-primary);
min-width: 0;
flex: 1 1 auto;
margin-inline-start: 1em;
display: inline-block;
cursor: pointer;
border: none;
height: 2.5rem;
line-height: 2.5rem;
padding: 0 0.5rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: var(--button-gray-1);
}
.RoomDialog-emoji {
font-family: sans-serif;
}
.RoomDialog-usernameContainer {
display: flex;
margin: 1.5em 0;
display: flex;
align-items: center;
justify-content: center;
}
.RoomDialog-username {
background-color: var(--input-background-color);
border-color: var(--input-border-color);
appearance: none;
min-width: 0;
flex: 1 1 auto;
margin-inline-start: 1em;
height: 2.5rem;
font-size: 1em;
line-height: 1.5;
padding: 0 0.5rem;
}
.RoomDialog-sessionStartButtonContainer {
display: flex;
justify-content: center;
}
.Modal .RoomDialog-stopSession {
background-color: var(--button-destructive-background-color);
.ToolIcon__label,
.ToolIcon__icon svg {
color: var(--button-destructive-color);
}
}
}

View file

@ -0,0 +1,136 @@
import React, { useRef } from "react";
import { t } from "../../i18n";
import { Dialog } from "../../components/Dialog";
import { copyTextToSystemClipboard } from "../../clipboard";
import { ToolButton } from "../../components/ToolButton";
import { clipboard, start, stop } from "../../components/icons";
import "./RoomDialog.scss";
import { EVENT_SHARE, trackEvent } from "../../analytics";
const RoomDialog = ({
handleClose,
activeRoomLink,
username,
onUsernameChange,
onRoomCreate,
onRoomDestroy,
setErrorMessage,
}: {
handleClose: () => void;
activeRoomLink: string;
username: string;
onUsernameChange: (username: string) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
setErrorMessage: (message: string) => void;
}) => {
const roomLinkInput = useRef<HTMLInputElement>(null);
const copyRoomLink = async () => {
try {
await copyTextToSystemClipboard(activeRoomLink);
trackEvent(EVENT_SHARE, "copy link");
} catch (error) {
setErrorMessage(error.message);
}
if (roomLinkInput.current) {
roomLinkInput.current.select();
}
};
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
if (event.target !== document.activeElement) {
event.preventDefault();
(event.target as HTMLInputElement).select();
}
};
const renderRoomDialog = () => {
return (
<div className="RoomDialog-modal">
{!activeRoomLink && (
<>
<p>{t("roomDialog.desc_intro")}</p>
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-startSession"
type="button"
icon={start}
title={t("roomDialog.button_startSession")}
aria-label={t("roomDialog.button_startSession")}
showAriaLabel={true}
onClick={onRoomCreate}
/>
</div>
</>
)}
{activeRoomLink && (
<>
<p>{t("roomDialog.desc_inProgressIntro")}</p>
<p>{t("roomDialog.desc_shareLink")}</p>
<div className="RoomDialog-linkContainer">
<ToolButton
type="button"
icon={clipboard}
title={t("labels.copy")}
aria-label={t("labels.copy")}
onClick={copyRoomLink}
/>
<input
value={activeRoomLink}
readOnly={true}
className="RoomDialog-link"
ref={roomLinkInput}
onPointerDown={selectInput}
/>
</div>
<div className="RoomDialog-usernameContainer">
<label className="RoomDialog-usernameLabel" htmlFor="username">
{t("labels.yourName")}
</label>
<input
id="username"
value={username || ""}
className="RoomDialog-username TextInput"
onChange={(event) => onUsernameChange(event.target.value)}
onBlur={() => trackEvent(EVENT_SHARE, "name")}
onKeyPress={(event) => event.key === "Enter" && handleClose()}
/>
</div>
<p>
<span role="img" aria-hidden="true" className="RoomDialog-emoji">
{"🔒"}
</span>{" "}
{t("roomDialog.desc_privacy")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-stopSession"
type="button"
icon={stop}
title={t("roomDialog.button_stopSession")}
aria-label={t("roomDialog.button_stopSession")}
showAriaLabel={true}
onClick={onRoomDestroy}
/>
</div>
</>
)}
</div>
);
};
return (
<Dialog
maxWidth={800}
onCloseRequest={handleClose}
title={t("labels.createRoom")}
>
{renderRoomDialog()}
</Dialog>
);
};
export default RoomDialog;