diff --git a/src/Presence.js b/src/Presence.js index b1e85e4bc734..b4e46f6c6724 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -23,15 +23,25 @@ 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 */ } } } @@ -39,13 +49,12 @@ class Presence { * 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; } /** @@ -56,21 +65,25 @@ 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; @@ -78,42 +91,14 @@ class Presence { 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(); diff --git a/src/UserActivity.js b/src/UserActivity.js index c628ab41861e..b952458f25cc 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -15,8 +15,9 @@ 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; /** @@ -24,23 +25,48 @@ const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000; * 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; } /** @@ -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 }); } @@ -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 @@ -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(); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index c44655ff1876..9f4d482cc010 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -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); @@ -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(); @@ -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(); @@ -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. diff --git a/src/utils/Timer.js b/src/utils/Timer.js new file mode 100644 index 000000000000..dcceff55c5ea --- /dev/null +++ b/src/utils/Timer.js @@ -0,0 +1,79 @@ +/* +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export default class Timer { + + constructor(timeout) { + this._wasStarted = false; + this._timeout = timeout; + this._timerHandle = null; + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }).finally(() => { + this._timerHandle = null; + }); + } + + start() { + if (!_wasStarted && !this.isRunning()) { + this._wasStarted = true; + this._timerHandle = setTimeout(this._resolve, this._timeout); + } + return this; + } + + reset() { + if (this.isRunning()) { + clearTimeout(this._timerHandle); + this._timerHandle = setTimeout(this._resolve, this._timeout); + } + return this; + } + + abort() { + if (this.isRunning()) { + clearTimeout(this._timerHandle); + this._reject(new Error("Aborted")); + } + return this; + } + + /** + Clones the timer if it's promise resolved already, + otherwise returns the same instance + @return {Timer} a new or the same timer + */ + cloneIfRan() { + if (this._wasStarted && !this.isRunning()) { + return this.clone(); + } else { + return this; + } + } + + clone() { + return new Timer(this._timeout); + } + + promise() { + return this._promise; + } + + isRunning() { + return this._timerHandle !== null; + } +}