element-web/src/UserActivity.ts

226 lines
8.5 KiB
TypeScript

/*
Copyright 2019-2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import dis from "./dispatcher/dispatcher";
import Timer from "./utils/Timer";
// important these are larger than the timeouts of timers
// used with UserActivity.timeWhileActive*,
// such as READ_MARKER_INVIEW_THRESHOLD_MS (timeWhileActiveRecently),
// READ_MARKER_OUTOFVIEW_THRESHOLD_MS (timeWhileActiveRecently),
// READ_RECEIPT_INTERVAL_MS (timeWhileActiveNow) in TimelinePanel
// 'Under a few seconds'. Must be less than 'RECENTLY_ACTIVE_THRESHOLD_MS'
const CURRENTLY_ACTIVE_THRESHOLD_MS = 700;
// 'Under a few minutes'.
const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
/**
* This class watches for user activity (moving the mouse or pressing a key)
* and starts/stops attached timers while the user is active.
*
* There are two classes of 'active': 'active now' and 'active recently'
* see doc on the userActive* functions for what these mean.
*/
export default class UserActivity {
private readonly activeNowTimeout: Timer;
private readonly activeRecentlyTimeout: Timer;
private attachedActiveNowTimers: Timer[] = [];
private attachedActiveRecentlyTimers: Timer[] = [];
private lastScreenX = 0;
private lastScreenY = 0;
public constructor(
private readonly window: Window,
private readonly document: Document,
) {
this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS);
}
public static sharedInstance(): UserActivity {
if (window.mxUserActivity === undefined) {
window.mxUserActivity = new UserActivity(window, document);
}
return window.mxUserActivity;
}
/**
* Runs the given timer while the user is 'active now', aborting when the user is no longer
* considered currently active.
* See userActiveNow() for what it means for a user to be 'active'.
* Can be called multiple times with the same already running timer, which is a NO-OP.
* Can be called before the user becomes active, in which case it is only started
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
public timeWhileActiveNow(timer: Timer): void {
this.timeWhile(timer, this.attachedActiveNowTimers);
if (this.userActiveNow()) {
timer.start();
}
}
/**
* Runs the given timer while the user is 'active' now or recently,
* aborting when the user becomes inactive.
* See userActiveRecently() for what it means for a user to be 'active recently'.
* Can be called multiple times with the same already running timer, which is a NO-OP.
* Can be called before the user becomes active, in which case it is only started
* later on when the user does become active.
* @param {Timer} timer the timer to use
*/
public timeWhileActiveRecently(timer: Timer): void {
this.timeWhile(timer, this.attachedActiveRecentlyTimers);
if (this.userActiveRecently()) {
timer.start();
}
}
private timeWhile(timer: Timer, attachedTimers: Timer[]): void {
// important this happens first
const index = attachedTimers.indexOf(timer);
if (index === -1) {
attachedTimers.push(timer);
// remove when done or aborted
timer
.finished()
.finally(() => {
const index = attachedTimers.indexOf(timer);
if (index !== -1) {
// should never be -1
attachedTimers.splice(index, 1);
}
// as we fork the promise here,
// avoid unhandled rejection warnings
})
.catch((err) => {});
}
}
/**
* Start listening to user activity
*/
public start(): void {
this.document.addEventListener("mousedown", this.onUserActivity);
this.document.addEventListener("mousemove", this.onUserActivity);
this.document.addEventListener("keydown", this.onUserActivity);
this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.addEventListener("blur", this.onWindowBlurred);
this.window.addEventListener("focus", this.onUserActivity);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
this.window.addEventListener("wheel", this.onUserActivity, {
passive: true,
capture: true,
});
}
/**
* Stop tracking user activity
*/
public stop(): void {
this.document.removeEventListener("mousedown", this.onUserActivity);
this.document.removeEventListener("mousemove", this.onUserActivity);
this.document.removeEventListener("keydown", this.onUserActivity);
this.window.removeEventListener("wheel", this.onUserActivity, {
capture: true,
});
this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged);
this.window.removeEventListener("blur", this.onWindowBlurred);
this.window.removeEventListener("focus", this.onUserActivity);
}
/**
* Return true if the user is currently 'active'
* A user is 'active' while they are interacting with the app and for a very short (<1s)
* time after that. This is intended to give a strong indication that the app has the
* user's attention at any given moment.
* @returns {boolean} true if user is currently 'active'
*/
public userActiveNow(): boolean {
return this.activeNowTimeout.isRunning();
}
/**
* Return true if the user is currently active or has been recently
* A user is 'active recently' for a longer period of time (~2 mins) after
* they have been 'active' and while the app still has the focus. This is
* intended to indicate when the app may still have the user's attention
* (or they may have gone to make tea and left the window focused).
* @returns {boolean} true if user has been active recently
*/
public userActiveRecently(): boolean {
return this.activeRecentlyTimeout.isRunning();
}
private onPageVisibilityChanged = (e: Event): void => {
if (this.document.visibilityState === "hidden") {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
} else {
this.onUserActivity(e);
}
};
private onWindowBlurred = (): void => {
this.activeNowTimeout.abort();
this.activeRecentlyTimeout.abort();
};
// XXX: exported for tests
public onUserActivity = (event: Event): void => {
// ignore anything if the window isn't focused
if (!this.document.hasFocus()) return;
if (event.type === "mousemove" && this.isMouseEvent(event)) {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
return;
}
this.lastScreenX = event.screenX;
this.lastScreenY = event.screenY;
}
dis.dispatch({ action: "user_activity" });
if (!this.activeNowTimeout.isRunning()) {
this.activeNowTimeout.start();
dis.dispatch({ action: "user_activity_start" });
UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout);
} else {
this.activeNowTimeout.restart();
}
if (!this.activeRecentlyTimeout.isRunning()) {
this.activeRecentlyTimeout.start();
UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout);
} else {
this.activeRecentlyTimeout.restart();
}
};
private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer): Promise<void> {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();
} catch {
/* aborted */
}
attachedTimers.forEach((t) => t.abort());
}
private isMouseEvent(event: Event): event is MouseEvent {
return event.type.startsWith("mouse");
}
}