Fix instantly sending RRs

Splits UserActivity into a tristate of 'active' (last < 1s), 'passive' (lasts a
couple of mins) and neither. Read receipts are sent when 'active', read markers
are sent while 'passive'.

Also fixed a document / window mix-up on the 'blur' handler.

Also adds a unit test for UserActivity because it's quite complex now
(and changes UserActivity to make it testable by accessing the singleton
via sharedInstance() rather than exporting it directly).

Fixes https://github.com/vector-im/riot-web/issues/9023
This commit is contained in:
David Baker 2019-03-08 12:46:38 +00:00
parent 2e081982ee
commit ce1623691e
4 changed files with 250 additions and 51 deletions

View file

@ -23,16 +23,25 @@ import Timer from './utils/Timer';
// such as READ_MARKER_INVIEW_THRESHOLD_MS,
// READ_MARKER_OUTOFVIEW_THRESHOLD_MS,
// READ_RECEIPT_INTERVAL_MS in TimelinePanel
const CURRENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000;
const CURRENTLY_ACTIVE_THRESHOLD_MS = 700;
const CURRENTLY_PASSIVE_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' a user can be: 'active' and 'passive':
* see doc on the userCurrently* functions for what these mean.
*/
class UserActivity {
constructor() {
this._attachedTimers = [];
this._activityTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
export default class UserActivity {
constructor(windowObj, documentObj) {
this._window = windowObj;
this._document = documentObj;
this._attachedTimersActive = [];
this._attachedTimersPassive = [];
this._activeTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS);
this._passiveTimeout = new Timer(CURRENTLY_PASSIVE_THRESHOLD_MS);
this._onUserActivity = this._onUserActivity.bind(this);
this._onWindowBlurred = this._onWindowBlurred.bind(this);
this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this);
@ -40,48 +49,76 @@ class UserActivity {
this.lastScreenY = 0;
}
static sharedInstance() {
if (global.mxUserActivity === undefined) {
global.mxUserActivity = new UserActivity(window, document);
}
return global.mxUserActivity;
}
/**
* Runs the given timer while the user is active, aborting when the user becomes inactive.
* Runs the given timer while the user is 'active', aborting when the user becomes 'passive'.
* See userCurrentlyActive() 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
*/
timeWhileActive(timer) {
this._timeWhile(timer, this._attachedTimersActive);
if (this.userCurrentlyActive()) {
timer.start();
}
}
/**
* Runs the given timer while the user is 'active' or 'passive', aborting when the user becomes
* inactive.
* See userCurrentlyPassive() for what it means for a user to be 'passive'.
* 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
*/
timeWhilePassive(timer) {
this._timeWhile(timer, this._attachedTimersPassive);
if (this.userCurrentlyPassive()) {
timer.start();
}
}
_timeWhile(timer, attachedTimers) {
// important this happens first
const index = this._attachedTimers.indexOf(timer);
const index = attachedTimers.indexOf(timer);
if (index === -1) {
this._attachedTimers.push(timer);
attachedTimers.push(timer);
// remove when done or aborted
timer.finished().finally(() => {
const index = this._attachedTimers.indexOf(timer);
const index = attachedTimers.indexOf(timer);
if (index !== -1) { // should never be -1
this._attachedTimers.splice(index, 1);
attachedTimers.splice(index, 1);
}
// as we fork the promise here,
// avoid unhandled rejection warnings
}).catch((err) => {});
}
if (this.userCurrentlyActive()) {
timer.start();
}
}
/**
* Start listening to user activity
*/
start() {
document.onmousedown = this._onUserActivity;
document.onmousemove = this._onUserActivity;
document.onkeydown = this._onUserActivity;
document.addEventListener("visibilitychange", this._onPageVisibilityChanged);
window.addEventListener("blur", this._onWindowBlurred);
window.addEventListener("focus", this._onUserActivity);
this._document.onmousedown = this._onUserActivity;
this._document.onmousemove = this._onUserActivity;
this._document.onkeydown = 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.
window.addEventListener('wheel', this._onUserActivity,
this._window.addEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
}
@ -89,39 +126,57 @@ class UserActivity {
* Stop tracking user activity
*/
stop() {
document.onmousedown = undefined;
document.onmousemove = undefined;
document.onkeydown = undefined;
window.removeEventListener('wheel', this._onUserActivity,
this._document.onmousedown = undefined;
this._document.onmousemove = undefined;
this._document.onkeydown = undefined;
this._window.removeEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
document.removeEventListener("blur", this._onDocumentBlurred);
document.removeEventListener("focus", this._onUserActivity);
this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged);
this._window.removeEventListener("blur", this._onWindowBlurred);
this._window.removeEventListener("focus", this._onUserActivity);
}
/**
* Return true if there has been user activity very recently
* (ie. within a few seconds)
* @returns {boolean} true if user is currently/very recently active
* 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'
*/
userCurrentlyActive() {
return this._activityTimeout.isRunning();
return this._activeTimeout.isRunning();
}
/**
* Return true if the user is currently 'active' or 'passive'
* A user is 'passive' 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 is currently 'active' or 'passive'
*/
userCurrentlyPassive() {
return this._passiveTimeout.isRunning();
}
_onPageVisibilityChanged(e) {
if (document.visibilityState === "hidden") {
this._activityTimeout.abort();
if (this._document.visibilityState === "hidden") {
this._activeTimeout.abort();
this._passiveTimeout.abort();
} else {
this._onUserActivity(e);
}
}
_onWindowBlurred() {
this._activityTimeout.abort();
this._activeTimeout.abort();
this._passiveTimeout.abort();
}
async _onUserActivity(event) {
_onUserActivity(event) {
// ignore anything if the window isn't focused
if (!this._document.hasFocus()) return;
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
@ -132,19 +187,29 @@ class UserActivity {
}
dis.dispatch({action: 'user_activity'});
if (!this._activityTimeout.isRunning()) {
this._activityTimeout.start();
if (!this._activeTimeout.isRunning()) {
this._activeTimeout.start();
dis.dispatch({action: 'user_activity_start'});
this._attachedTimers.forEach((t) => t.start());
try {
await this._activityTimeout.finished();
} catch (_e) { /* aborted */ }
this._attachedTimers.forEach((t) => t.abort());
this._runTimersUntilTimeout(this._attachedTimersActive, this._activeTimeout);
} else {
this._activityTimeout.restart();
this._activeTimeout.restart();
}
if (!this._passiveTimeout.isRunning()) {
this._passiveTimeout.start();
this._runTimersUntilTimeout(this._attachedTimersPassive, this._passiveTimeout);
} else {
this._passiveTimeout.restart();
}
}
async _runTimersUntilTimeout(attachedTimers, timeout) {
attachedTimers.forEach((t) => t.start());
try {
await timeout.finished();
} catch (_e) { /* aborted */ }
attachedTimers.forEach((t) => t.abort());
}
}
module.exports = new UserActivity();