Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
WIP on multiple user activity timeouts
Browse files Browse the repository at this point in the history
  • Loading branch information
bwindels committed Dec 5, 2018
1 parent 12ca38f commit 82d5873
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 90 deletions.
79 changes: 32 additions & 47 deletions src/Presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,29 +23,38 @@ const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins
const PRESENCE_STATES = ["online", "offline", "unavailable"];

class Presence {

constructor() {
this._activitySignal = null;
this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS);
this._onAction = this._onAction.bind(this);
this._dispatcherRef = null;
}
/**
* Start listening the user activity to evaluate his presence state.
* Any state change will be sent to the Home Server.
*/
start() {
this.running = true;
if (undefined === this.state) {
this._resetTimer();
this.dispatcherRef = dis.register(this._onAction.bind(this));
async start() {
// the user_activity_start action starts the timer
this._dispatcherRef = dis.register(this._onAction);
while (this._unavailableTimer) {
try {
await this._unavailableTimer.promise(); // won't resolve until started
this.setState("unavailable");
} catch(e) { /* aborted, stop got called */ }
}
}

/**
* Stop tracking user activity
*/
stop() {
this.running = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
dis.unregister(this.dispatcherRef);
if (this._dispatcherRef) {
dis.unregister(this._dispatcherRef);
this._dispatcherRef = null;
}
this.state = undefined;
this._unavailableTimer.abort();
this._unavailableTimer = null;
}

/**
Expand All @@ -56,64 +65,40 @@ class Presence {
return this.state;
}

_onAction(payload) {
if (payload.action === 'user_activity_start') {
this.setState("online");
this._unavailableTimer.cloneIfRan().start().reset();
}
}

/**
* Set the presence state.
* If the state has changed, the Home Server will be notified.
* @param {string} newState the new presence state (see PRESENCE enum)
*/
setState(newState) {
async setState(newState) {
if (newState === this.state) {
return;
}
if (PRESENCE_STATES.indexOf(newState) === -1) {
throw new Error("Bad presence state: " + newState);
}
if (!this.running) {
return;
}
const old_state = this.state;
this.state = newState;

if (MatrixClientPeg.get().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}

const self = this;
MatrixClientPeg.get().setPresence(this.state).done(function() {
try {
await MatrixClientPeg.get().setPresence(this.state);
console.log("Presence: %s", newState);
}, function(err) {
} catch(err) {
console.error("Failed to set presence: %s", err);
self.state = old_state;
});
}

/**
* Callback called when the user made no action on the page for UNAVAILABLE_TIME ms.
* @private
*/
_onUnavailableTimerFire() {
this.setState("unavailable");
}

_onAction(payload) {
if (payload.action === "user_activity") {
this._resetTimer();
this.state = old_state;
}
}

/**
* Callback called when the user made an action on the page
* @private
*/
_resetTimer() {
const self = this;
this.setState("online");
// Re-arm the timer
clearTimeout(this.timer);
this.timer = setTimeout(function() {
self._onUnavailableTimerFire();
}, UNAVAILABLE_TIME_MS);
}
}

module.exports = new Presence();
78 changes: 46 additions & 32 deletions src/UserActivity.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,32 +15,58 @@ limitations under the License.
*/

import dis from './dispatcher';
import Timer from './utils/Timer';

const MIN_DISPATCH_INTERVAL_MS = 500;
const ACTIVITY_TIMEOUT = 500;
const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000;

/**
* This class watches for user activity (moving the mouse or pressing a key)
* and dispatches the user_activity action at times when the user is interacting
* with the app (but at a much lower frequency than mouse move events)
*/

class UserActivity {

constructor() {
this._activityTimers = [];
this._activityTimeout = new Timer(ACTIVITY_TIMEOUT);
this._onUserActivity = this._onUserActivity.bind(this);
}
// can be an already registered timer
// can be an already running timer
// will only clone if the timer is spent and can't be resolved again
// will start the timer once the user is active
timeWhileActive(timer) {
const index = this._activityTimers.indexOf(timer);
if (index === -1) {
this._activityTimers.push(timer);
// remove when done or aborted
timer.promise().finally(() => {
const index = this._activityTimers.indexOf(timer);
this._activityTimers.splice(index, 1);
});
}
timer = timer.cloneIfRan();
if (this.userCurrentlyActive()) {
timer.start();
}
return timer;
}

/**
* Start listening to user activity
*/
start() {
document.onmousedown = this._onUserActivity.bind(this);
document.onmousemove = this._onUserActivity.bind(this);
document.onkeydown = this._onUserActivity.bind(this);
document.onmousedown = this._onUserActivity;
document.onmousemove = this._onUserActivity;
document.onkeydown = 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.bind(this),
window.addEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
this.lastActivityAtTs = new Date().getTime();
this.lastDispatchAtTs = 0;
this.activityEndTimer = undefined;
}

/**
Expand All @@ -50,7 +76,7 @@ class UserActivity {
document.onmousedown = undefined;
document.onmousemove = undefined;
document.onkeydown = undefined;
window.removeEventListener('wheel', this._onUserActivity.bind(this),
window.removeEventListener('wheel', this._onUserActivity,
{ passive: true, capture: true });
}

Expand All @@ -60,10 +86,10 @@ class UserActivity {
* @returns {boolean} true if user is currently/very recently active
*/
userCurrentlyActive() {
return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS;
return this._activityTimeout.isRunning();
}

_onUserActivity(event) {
async _onUserActivity(event) {
if (event.screenX && event.type === "mousemove") {
if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) {
// mouse hasn't actually moved
Expand All @@ -73,30 +99,18 @@ class UserActivity {
this.lastScreenY = event.screenY;
}

this.lastActivityAtTs = new Date().getTime();
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({
action: 'user_activity',
});
if (!this.activityEndTimer) {
this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS);
}
}
}

_onActivityEndTimer() {
const now = new Date().getTime();
const targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
if (now >= targetTime) {
dis.dispatch({
action: 'user_activity_end',
});
this.activityEndTimer = undefined;
if (!this._activityTimeout.isRunning()) {
this._activityTimeout = this._activityTimeout.clone();
this._activityTimeout.start();
dis.dispatch({action: 'user_activity_start'});
this._activityTimers.forEach((t) => t.start());
await this._activityTimeout.promise();
this._activityTimers.forEach((t) => t.abort());
} else {
this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now);
this._activityTimeout.reset();
}
}
}


module.exports = new UserActivity();
55 changes: 44 additions & 11 deletions src/components/structures/TimelinePanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ var TimelinePanel = React.createClass({
this.lastRRSentEventId = undefined;
this.lastRMSentEventId = undefined;

this.updateReadMarkerOnActivity();
this.updateReadReceiptOnActivity();

this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
Expand Down Expand Up @@ -255,6 +258,12 @@ var TimelinePanel = React.createClass({
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;

// stop updating
this._readReceiptActivityTimer.abort();
this._readReceiptActivityTimer = null;
this._readMarkerActivityTimer.abort();
this._readMarkerActivityTimer = null;

dis.unregister(this.dispatcherRef);

const client = MatrixClientPeg.get();
Expand Down Expand Up @@ -371,17 +380,31 @@ var TimelinePanel = React.createClass({
}
},

onAction: function(payload) {
switch (payload.action) {
case 'user_activity':
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
// the actual user activity and the time they stopped
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt();
updateReadMarkerOnActivity: async function() {
this._readMarkerActivityTimer = new Timer(20000);
while (this._readMarkerActivityTimer) { //unset on unmount
this._readMarkerActivityTimer = UserActivity.timeWhileActive(_readMarkerActivityTimer);
try {
await this._readMarkerActivityTimer.promise();
this.updateReadMarker();
} catch(e) { /* aborted */ }
}
},

updateReadReceiptOnActivity: async function() {
this._readReceiptActivityTimer = new Timer(500);
while (this._readReceiptActivityTimer) { //unset on unmount
this._readReceiptActivityTimer = UserActivity.timeWhileActive(_readReceiptActivityTimer);
try {
await this._readReceiptActivityTimer.promise();
this.sendReadReceipt();
} catch(e) { /* aborted */ }
}
},




break;
case 'ignore_state_changed':
this.forceUpdate();
Expand Down Expand Up @@ -632,12 +655,22 @@ var TimelinePanel = React.createClass({

// if the read marker is on the screen, we can now assume we've caught up to the end
// of the screen, so move the marker down to the bottom of the screen.
updateReadMarker: function() {
updateReadMarker: async function() {
if (!this.props.manageReadMarkers) return;
if (this.getReadMarkerPosition() !== 0) {
return;
}

if (this._readMarkerActivityTimer.isRunning()) {
return;
}
this._readMarkerActivityTimer = UserActivity.timeWhileActive(20000);
try {
await this._readMarkerActivityTimer.promise();
} catch(e) {
return; //aborted
}

// move the RM to *after* the message at the bottom of the screen. This
// avoids a problem whereby we never advance the RM if there is a huge
// message which doesn't fit on the screen.
Expand Down
Loading

0 comments on commit 82d5873

Please sign in to comment.