feat: Add idle detection to collaboration feature (#2877)

* 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 <lipiridis@gmail.com>
This commit is contained in:
Thomas Steiner 2021-02-04 11:55:43 +01:00 committed by GitHub
parent 15f698dc21
commit 1837147c55
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 156 additions and 6 deletions

View File

@ -57,6 +57,7 @@
<!-- Excalidraw version --> <!-- Excalidraw version -->
<meta name="version" content="{version}" /> <meta name="version" content="{version}" />
<link <link
rel="preload" rel="preload"
href="FG_Virgil.woff2" href="FG_Virgil.woff2"

View File

@ -882,6 +882,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {}; const pointerViewportCoords: SceneState["remotePointerViewportCoords"] = {};
const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {}; const remoteSelectedElementIds: SceneState["remoteSelectedElementIds"] = {};
const pointerUsernames: { [id: string]: string } = {}; const pointerUsernames: { [id: string]: string } = {};
const pointerUserStates: { [id: string]: string } = {};
this.state.collaborators.forEach((user, socketId) => { this.state.collaborators.forEach((user, socketId) => {
if (user.selectedElementIds) { if (user.selectedElementIds) {
for (const id of Object.keys(user.selectedElementIds)) { for (const id of Object.keys(user.selectedElementIds)) {
@ -897,6 +898,9 @@ class App extends React.Component<ExcalidrawProps, AppState> {
if (user.username) { if (user.username) {
pointerUsernames[socketId] = user.username; pointerUsernames[socketId] = user.username;
} }
if (user.userState) {
pointerUserStates[socketId] = user.userState;
}
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords( pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{ {
sceneX: user.pointer.x, sceneX: user.pointer.x,
@ -931,6 +935,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
remotePointerButton: cursorButton, remotePointerButton: cursorButton,
remoteSelectedElementIds, remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames, remotePointerUsernames: pointerUsernames,
remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom, shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
}, },
{ {

View File

@ -46,6 +46,7 @@ export enum EVENT {
TOUCH_START = "touchstart", TOUCH_START = "touchstart",
TOUCH_END = "touchend", TOUCH_END = "touchend",
HASHCHANGE = "hashchange", HASHCHANGE = "hashchange",
VISIBILITY_CHANGE = "visibilitychange",
} }
export const ENV = { export const ENV = {
@ -93,3 +94,8 @@ export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
export const ZOOM_STEP = 0.1; 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;

View File

@ -38,11 +38,14 @@ import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext"; import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { UserIdleState } from "./types";
import { IDLE_THRESHOLD, ACTIVE_THRESHOLD } from "../../constants";
interface CollabState { interface CollabState {
modalIsShown: boolean; modalIsShown: boolean;
errorMessage: string; errorMessage: string;
username: string; username: string;
userState: UserIdleState;
activeRoomLink: string; activeRoomLink: string;
} }
@ -52,6 +55,7 @@ export interface CollabAPI {
/** function so that we can access the latest value from stale callbacks */ /** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean; isCollaborating: () => boolean;
username: CollabState["username"]; username: CollabState["username"];
userState: CollabState["userState"];
onPointerUpdate: CollabInstance["onPointerUpdate"]; onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"]; initializeSocketClient: CollabInstance["initializeSocketClient"];
onCollabButtonClick: CollabInstance["onCollabButtonClick"]; onCollabButtonClick: CollabInstance["onCollabButtonClick"];
@ -78,6 +82,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false; isCollaborating: boolean = false;
activeIntervalId: number | null;
idleTimeoutId: number | null;
private socketInitializationTimer?: NodeJS.Timeout; private socketInitializationTimer?: NodeJS.Timeout;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
@ -89,10 +95,13 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
modalIsShown: false, modalIsShown: false,
errorMessage: "", errorMessage: "",
username: importUsernameFromLocalStorage() || "", username: importUsernameFromLocalStorage() || "",
userState: UserIdleState.ACTIVE,
activeRoomLink: "", activeRoomLink: "",
}; };
this.portal = new Portal(this); this.portal = new Portal(this);
this.excalidrawAPI = props.excalidrawAPI; this.excalidrawAPI = props.excalidrawAPI;
this.activeIntervalId = null;
this.idleTimeoutId = null;
} }
componentDidMount() { componentDidMount() {
@ -116,6 +125,19 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload); window.removeEventListener(EVENT.BEFORE_UNLOAD, this.beforeUnload);
window.removeEventListener(EVENT.UNLOAD, this.onUnload); 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 = () => { private onUnload = () => {
@ -318,6 +340,17 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}); });
break; 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;
}
} }
}, },
); );
@ -330,6 +363,8 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
scenePromise.resolve(null); scenePromise.resolve(null);
}); });
this.initializeIdleDetector();
this.setState({ this.setState({
activeRoomLink: window.location.href, activeRoomLink: window.location.href,
}); });
@ -398,7 +433,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
// Avoid broadcasting to the rest of the collaborators the scene // Avoid broadcasting to the rest of the collaborators the scene
// we just received! // we just received!
// Note: this needs to be set before updating the scene as it // Note: this needs to be set before updating the scene as it
// syncronously calls render. // synchronously calls render.
this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements)); this.setLastBroadcastedOrReceivedSceneVersion(getSceneVersion(newElements));
return newElements as ReconciledElements; return newElements as ReconciledElements;
@ -427,6 +462,58 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.excalidrawAPI.history.clear(); 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[]) { setCollaborators(sockets: string[]) {
this.setState((state) => { this.setState((state) => {
const collaborators: InstanceType< const collaborators: InstanceType<
@ -466,6 +553,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
this.portal.broadcastMouseLocation(payload); this.portal.broadcastMouseLocation(payload);
}; };
onIdleStateChange = (userState: UserIdleState) => {
this.setState({ userState });
this.portal.broadcastIdleChange(userState);
};
broadcastElements = (elements: readonly ExcalidrawElement[]) => { broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if ( if (
getSceneVersion(elements) > getSceneVersion(elements) >

View File

@ -9,6 +9,7 @@ import CollabWrapper from "./CollabWrapper";
import { getSyncableElements } from "../../packages/excalidraw/index"; import { getSyncableElements } from "../../packages/excalidraw/index";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { BROADCAST, SCENE } from "../app_constants"; import { BROADCAST, SCENE } from "../app_constants";
import { UserIdleState } from "./types";
class Portal { class Portal {
collab: CollabWrapper; collab: CollabWrapper;
@ -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: { broadcastMouseLocation = (payload: {
pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"]; pointer: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["pointer"];
button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"]; button: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["button"];

View File

@ -0,0 +1,5 @@
export enum UserIdleState {
ACTIVE = "active",
AWAY = "away",
IDLE = "idle",
}

View File

@ -4,6 +4,7 @@ import { ImportedDataState } from "../../data/types";
import { ExcalidrawElement } from "../../element/types"; import { ExcalidrawElement } from "../../element/types";
import { t } from "../../i18n"; import { t } from "../../i18n";
import { AppState } from "../../types"; import { AppState } from "../../types";
import { UserIdleState } from "../collab/types";
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2); const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
username: string; username: string;
}; };
}; };
IDLE_STATUS: {
type: "IDLE_STATUS";
payload: {
socketId: string;
userState: UserIdleState;
username: string;
};
};
}; };
export type SocketUpdateDataIncoming = export type SocketUpdateDataIncoming =

View File

@ -48,6 +48,7 @@ import {
TransformHandleType, TransformHandleType,
} from "../element/transformHandles"; } from "../element/transformHandles";
import { viewportCoordsToSceneCoords } from "../utils"; import { viewportCoordsToSceneCoords } from "../utils";
import { UserIdleState } from "../excalidraw-app/collab/types";
const strokeRectWithRotation = ( const strokeRectWithRotation = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@ -445,7 +446,9 @@ export const renderScene = (
const globalAlpha = context.globalAlpha; const globalAlpha = context.globalAlpha;
context.strokeStyle = stroke; context.strokeStyle = stroke;
context.fillStyle = background; context.fillStyle = background;
if (isOutOfBounds) {
const userState = sceneState.remotePointerUserStates[clientId];
if (isOutOfBounds || userState === UserIdleState.AWAY) {
context.globalAlpha = 0.2; context.globalAlpha = 0.2;
} }
@ -478,19 +481,25 @@ export const renderScene = (
context.stroke(); context.stroke();
const username = sceneState.remotePointerUsernames[clientId]; const username = sceneState.remotePointerUsernames[clientId];
const usernameAndIdleState = `${username ? `${username} ` : ""}${
userState === UserIdleState.AWAY
? "⚫️"
: userState === UserIdleState.IDLE
? "💤"
: "🟢"
}`;
if (!isOutOfBounds && username) { if (!isOutOfBounds && usernameAndIdleState) {
const offsetX = x + width; const offsetX = x + width;
const offsetY = y + height; const offsetY = y + height;
const paddingHorizontal = 4; const paddingHorizontal = 4;
const paddingVertical = 4; const paddingVertical = 4;
const measure = context.measureText(username); const measure = context.measureText(usernameAndIdleState);
const measureHeight = const measureHeight =
measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent;
// Border // Border
context.fillStyle = stroke; context.fillStyle = stroke;
context.globalAlpha = globalAlpha;
context.fillRect( context.fillRect(
offsetX - 1, offsetX - 1,
offsetY - 1, offsetY - 1,
@ -506,8 +515,9 @@ export const renderScene = (
measureHeight + 2 * paddingVertical, measureHeight + 2 * paddingVertical,
); );
context.fillStyle = oc.white; context.fillStyle = oc.white;
context.fillText( context.fillText(
username, usernameAndIdleState,
offsetX + paddingHorizontal, offsetX + paddingHorizontal,
offsetY + paddingVertical + measure.actualBoundingBoxAscent, offsetY + paddingVertical + measure.actualBoundingBoxAscent,
); );

View File

@ -65,6 +65,7 @@ export const exportToCanvas = (
remoteSelectedElementIds: {}, remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
remotePointerUsernames: {}, remotePointerUsernames: {},
remotePointerUserStates: {},
}, },
{ {
renderScrollbars: false, renderScrollbars: false,

View File

@ -12,6 +12,7 @@ export type SceneState = {
remotePointerButton?: { [id: string]: string | undefined }; remotePointerButton?: { [id: string]: string | undefined };
remoteSelectedElementIds: { [elementId: string]: string[] }; remoteSelectedElementIds: { [elementId: string]: string[] };
remotePointerUsernames: { [id: string]: string }; remotePointerUsernames: { [id: string]: string };
remotePointerUserStates: { [id: string]: string };
}; };
export type SceneScroll = { export type SceneScroll = {

View File

@ -20,6 +20,7 @@ import { ExcalidrawImperativeAPI } from "./components/App";
import type { ResolvablePromise } from "./utils"; import type { ResolvablePromise } from "./utils";
import { Spreadsheet } from "./charts"; import { Spreadsheet } from "./charts";
import { Language } from "./i18n"; import { Language } from "./i18n";
import { UserIdleState } from "./excalidraw-app/collab/types";
export type Point = Readonly<RoughPoint>; export type Point = Readonly<RoughPoint>;
@ -31,6 +32,7 @@ export type Collaborator = {
button?: "up" | "down"; button?: "up" | "down";
selectedElementIds?: AppState["selectedElementIds"]; selectedElementIds?: AppState["selectedElementIds"];
username?: string | null; username?: string | null;
userState?: UserIdleState;
}; };
export type AppState = { export type AppState = {