diff --git a/public/index.html b/public/index.html
index 24b76e86..28b4d043 100644
--- a/public/index.html
+++ b/public/index.html
@@ -57,6 +57,7 @@
+
{
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)) {
@@ -897,6 +898,9 @@ class App extends React.Component {
if (user.username) {
pointerUsernames[socketId] = user.username;
}
+ if (user.userState) {
+ pointerUserStates[socketId] = user.userState;
+ }
pointerViewportCoords[socketId] = sceneCoordsToViewportCoords(
{
sceneX: user.pointer.x,
@@ -931,6 +935,7 @@ class App extends React.Component {
remotePointerButton: cursorButton,
remoteSelectedElementIds,
remotePointerUsernames: pointerUsernames,
+ remotePointerUserStates: pointerUserStates,
shouldCacheIgnoreZoom: this.state.shouldCacheIgnoreZoom,
},
{
diff --git a/src/constants.ts b/src/constants.ts
index b037eb23..09c9e027 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -46,6 +46,7 @@ export enum EVENT {
TOUCH_START = "touchstart",
TOUCH_END = "touchend",
HASHCHANGE = "hashchange",
+ VISIBILITY_CHANGE = "visibilitychange",
}
export const ENV = {
@@ -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;
diff --git a/src/excalidraw-app/collab/CollabWrapper.tsx b/src/excalidraw-app/collab/CollabWrapper.tsx
index 051b523d..e22f0398 100644
--- a/src/excalidraw-app/collab/CollabWrapper.tsx
+++ b/src/excalidraw-app/collab/CollabWrapper.tsx
@@ -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;
}
@@ -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"];
@@ -78,6 +82,8 @@ class CollabWrapper extends PureComponent {
portal: Portal;
excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
+ activeIntervalId: number | null;
+ idleTimeoutId: number | null;
private socketInitializationTimer?: NodeJS.Timeout;
private lastBroadcastedOrReceivedSceneVersion: number = -1;
@@ -89,10 +95,13 @@ class CollabWrapper extends PureComponent {
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() {
@@ -116,6 +125,19 @@ class CollabWrapper extends PureComponent {
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 = () => {
@@ -318,6 +340,17 @@ class CollabWrapper extends PureComponent {
});
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 {
scenePromise.resolve(null);
});
+ this.initializeIdleDetector();
+
this.setState({
activeRoomLink: window.location.href,
});
@@ -398,7 +433,7 @@ class CollabWrapper extends PureComponent {
// 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;
@@ -427,6 +462,58 @@ class CollabWrapper extends PureComponent {
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<
@@ -466,6 +553,11 @@ class CollabWrapper extends PureComponent {
this.portal.broadcastMouseLocation(payload);
};
+ onIdleStateChange = (userState: UserIdleState) => {
+ this.setState({ userState });
+ this.portal.broadcastIdleChange(userState);
+ };
+
broadcastElements = (elements: readonly ExcalidrawElement[]) => {
if (
getSceneVersion(elements) >
diff --git a/src/excalidraw-app/collab/Portal.tsx b/src/excalidraw-app/collab/Portal.tsx
index 72ee6023..091f395f 100644
--- a/src/excalidraw-app/collab/Portal.tsx
+++ b/src/excalidraw-app/collab/Portal.tsx
@@ -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;
@@ -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"];
diff --git a/src/excalidraw-app/collab/types.ts b/src/excalidraw-app/collab/types.ts
new file mode 100644
index 00000000..f2b88826
--- /dev/null
+++ b/src/excalidraw-app/collab/types.ts
@@ -0,0 +1,5 @@
+export enum UserIdleState {
+ ACTIVE = "active",
+ AWAY = "away",
+ IDLE = "idle",
+}
diff --git a/src/excalidraw-app/data/index.ts b/src/excalidraw-app/data/index.ts
index 1d4aaf60..0843b549 100644
--- a/src/excalidraw-app/data/index.ts
+++ b/src/excalidraw-app/data/index.ts
@@ -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);
@@ -59,6 +60,14 @@ export type SocketUpdateDataSource = {
username: string;
};
};
+ IDLE_STATUS: {
+ type: "IDLE_STATUS";
+ payload: {
+ socketId: string;
+ userState: UserIdleState;
+ username: string;
+ };
+ };
};
export type SocketUpdateDataIncoming =
diff --git a/src/renderer/renderScene.ts b/src/renderer/renderScene.ts
index c802fde4..70b5c1a2 100644
--- a/src/renderer/renderScene.ts
+++ b/src/renderer/renderScene.ts
@@ -48,6 +48,7 @@ import {
TransformHandleType,
} from "../element/transformHandles";
import { viewportCoordsToSceneCoords } from "../utils";
+import { UserIdleState } from "../excalidraw-app/collab/types";
const strokeRectWithRotation = (
context: CanvasRenderingContext2D,
@@ -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;
}
@@ -478,19 +481,25 @@ export const renderScene = (
context.stroke();
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 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,
@@ -506,8 +515,9 @@ export const renderScene = (
measureHeight + 2 * paddingVertical,
);
context.fillStyle = oc.white;
+
context.fillText(
- username,
+ usernameAndIdleState,
offsetX + paddingHorizontal,
offsetY + paddingVertical + measure.actualBoundingBoxAscent,
);
diff --git a/src/scene/export.ts b/src/scene/export.ts
index bf58713a..144a2c01 100644
--- a/src/scene/export.ts
+++ b/src/scene/export.ts
@@ -65,6 +65,7 @@ export const exportToCanvas = (
remoteSelectedElementIds: {},
shouldCacheIgnoreZoom: false,
remotePointerUsernames: {},
+ remotePointerUserStates: {},
},
{
renderScrollbars: false,
diff --git a/src/scene/types.ts b/src/scene/types.ts
index 907acf23..cbeb7bc9 100644
--- a/src/scene/types.ts
+++ b/src/scene/types.ts
@@ -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 = {
diff --git a/src/types.ts b/src/types.ts
index dd31b9f8..2d8089d2 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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;
@@ -31,6 +32,7 @@ export type Collaborator = {
button?: "up" | "down";
selectedElementIds?: AppState["selectedElementIds"];
username?: string | null;
+ userState?: UserIdleState;
};
export type AppState = {