Skip to content

Commit

Permalink
feat: Add idle detection to collaboration feature (excalidraw#2877)
Browse files Browse the repository at this point in the history
* Start idle detection implementation

* First working version

* Add screen state

* Add type safety

* Better rendering, enum types, localization

* Add origin trial token

* Fix

* Refactor idle detection to no longer use IdleDetector API

* Cleanup some leftovers

* Fix

* Apply suggestions from code review

* Three state: active 🟢, idle 💤, away ⚫️

* Address feedback from code review
Thanks, @lipis

* Deal with unmount

Co-authored-by: Panayiotis Lipiridis <[email protected]>
  • Loading branch information
tomayac and lipis authored Feb 4, 2021
1 parent 15f698d commit 1837147
Show file tree
Hide file tree
Showing 11 changed files with 157 additions and 7 deletions.
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@

<!-- Excalidraw version -->
<meta name="version" content="{version}" />

<link
rel="preload"
href="FG_Virgil.woff2"
Expand Down
5 changes: 5 additions & 0 deletions src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) {
Expand All @@ -897,6 +898,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (user.username) {
pointerUsernames[socketId] = user.username;
}
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
Expand Down Expand Up @@ -931,6 +935,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
},
{
Expand Down
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export enum EVENT {
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
}

export const ENV = {
Expand Down Expand Up @@ -93,3 +94,8 @@ export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000;

export const ZOOM_STEP = 0.1;

// Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000;
// Report a user active each ACTIVE_THRESHOLD milliseconds
export const ACTIVE_THRESHOLD = 3_000;
94 changes: 93 additions & 1 deletion src/excalidraw-app/collab/CollabWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ import Portal from "./Portal";
import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
import { UserIdleState } from "./types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";

interface CollabState {
modalIsShown: boolean;
errorMessage: string;
username: string;
userState: UserIdleState;
activeRoomLink: string;
}

Expand All @@ -52,6 +55,7 @@ export interface CollabAPI {
/** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"];
Expand All @@ -78,6 +82,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;

private socketInitializationTimer?: NodeJS.Timeout;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
Expand All @@ -89,10 +95,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
modalIsShown: false,
errorMessage: "",
username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "",
};
this.portal = new Portal(this);
this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
}

componentDidMount() {
Expand All @@ -116,6 +125,19 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
componentWillUnmount() {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload);
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;
}
}

private onUnload = () => {
Expand Down Expand Up @@ -318,6 +340,17 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
});
break;
}
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;
}
}
},
);
Expand All @@ -330,6 +363,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
scenePromise.resolve(null);
});

this.initializeIdleDetector();

this.setState({
activeRoomLink: window.location.href,
});
Expand Down Expand Up @@ -398,7 +433,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// 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.
// synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));

return newElements as ReconciledElements;
Expand Down Expand Up @@ -427,6 +462,58 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.excalidrawAPI.history.clear();
};

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);
};

setCollaborators(sockets: string[]) {
this.setState((state) => {
const collaborators: InstanceType<
Expand Down Expand Up @@ -466,6 +553,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastMouseLocation(payload);
};

onIdleStateChange = (userState: UserIdleState) => {
this.setState({ userState });
this.portal.broadcastIdleChange(userState);
};

broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
Expand Down
18 changes: 18 additions & 0 deletions src/excalidraw-app/collab/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import CollabWrapper from "./CollabWrapper";
import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "./types";

class Portal {
collab: CollabWrapper;
Expand Down Expand Up @@ -132,6 +133,23 @@ class Portal {
}
};

broadcastIdleChange = (userState: UserIdleState) => {
if (this.socket?.id) {
const data: SocketUpdateDataSource["IDLE_STATUS"] = {
type: "IDLE_STATUS",
payload: {
socketId: this.socket.id,
userState,
username: this.collab.state.username,
},
};
return this._broadcastSocketData(
data as SocketUpdateData,
true, // volatile
);
}
};

broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];
Expand Down
5 changes: 5 additions & 0 deletions src/excalidraw-app/collab/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}
9 changes: 9 additions & 0 deletions src/excalidraw-app/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n";
import { AppState } from "../../types";
import { UserIdleState } from "../collab/types";

const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);

Expand Down Expand Up @@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
username: string;
};
};
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {
socketId: string;
userState: UserIdleState;
username: string;
};
};
};

export type SocketUpdateDataIncoming =
Expand Down
22 changes: 16 additions & 6 deletions src/renderer/renderScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
TransformHandleType,
} from "../element/transformHandles";
import { viewportCoordsToSceneCoords } from "../utils";
import { UserIdleState } from "../excalidraw-app/collab/types";

const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
Expand Down Expand Up @@ -445,7 +446,9 @@ export const renderScene = (
const globalAlpha = context.globalAlpha;
context.strokeStyle = stroke;
context.fillStyle = background;
if (isOutOfBounds) {

const userState = sceneState.remotePointerUserStates[clientId];
if (isOutOfBounds || userState === UserIdleState.AWAY) {
context.globalAlpha = 0.2;
}

Expand Down Expand Up @@ -478,19 +481,25 @@ export const renderScene = (
context.stroke();

const username = sceneState.remotePointerUsernames[clientId];

if (!isOutOfBounds && username) {
const usernameAndIdleState = `${username ? `${username} ` : ""}${
userState === UserIdleState.AWAY
? "⚫️"
: userState === UserIdleState.IDLE
? "💤"
: "🟢"
}`;

if (!isOutOfBounds && usernameAndIdleState) {
const offsetX = x + width;
const offsetY = y + height;
const paddingHorizontal = 4;
const paddingVertical = 4;
const measure = context.measureText(username);
const measure = context.measureText(usernameAndIdleState);
const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;

// Border
context.fillStyle = stroke;
context.globalAlpha = globalAlpha;
context.fillRect(
offsetX - 1,
offsetY - 1,
Expand All @@ -506,8 +515,9 @@ export const renderScene = (
measureHeight + 2 * paddingVertical,
);
context.fillStyle = oc.white;

context.fillText(
username,
usernameAndIdleState,
offsetX + paddingHorizontal,
offsetY + paddingVertical + measure.actualBoundingBoxAscent,
);
Expand Down
1 change: 1 addition & 0 deletions src/scene/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const exportToCanvas = (
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
remotePointerUserStates: {},
},
{
renderScrollbars: false,
Expand Down
1 change: 1 addition & 0 deletions src/scene/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type SceneState = {
remotePointerButton?: { [id: string]: string | undefined };
remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerUsernames: { [id: string]: string };
remotePointerUserStates: { [id: string]: string };
};

export type SceneScroll = {
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ExcalidrawImperativeAPI } from "./components/App";
import type { ResolvablePromise } from "./utils";
import { Spreadsheet } from "./charts";
import { Language } from "./i18n";
import { UserIdleState } from "./excalidraw-app/collab/types";

export type Point = Readonly<RoughPoint>;

Expand All @@ -31,6 +32,7 @@ export type Collaborator = {
button?: "up" | "down";
selectedElementIds?: AppState["selectedElementIds"];
username?: string | null;
userState?: UserIdleState;
};

export type AppState = {
Expand Down

0 comments on commit 1837147

Please sign in to comment.