diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 67facaa3225..72dca953f9d 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -87,8 +87,12 @@ limitations under the License. flex: 1; } -.mx_RoomView_body .mx_RoomView_topUnreadMessagesBar { - order: 1; +.mx_RoomView_body .mx_RoomView_timeline { + /* offset parent for mx_RoomView_topUnreadMessagesBar */ + position: relative; + flex: 1; + display: flex; + flex-direction: column; } .mx_RoomView_body .mx_RoomView_messagePanel { diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.scss b/res/css/views/rooms/_TopUnreadMessagesBar.scss index 1ee56d9532f..c4ca035a2ec 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.scss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.scss @@ -15,39 +15,29 @@ limitations under the License. */ .mx_TopUnreadMessagesBar { - margin: auto; /* centre horizontally */ - max-width: 960px; - padding-top: 10px; - padding-bottom: 10px; - border-bottom: 1px solid $primary-hairline-color; + z-index: 1000; + position: absolute; + top: 24px; + right: 24px; + width: 38px; } .mx_TopUnreadMessagesBar_scrollUp { - display: inline; + height: 38px; + border-radius: 19px; + box-sizing: border-box; + background: $primary-bg-color; + border: 1.3px solid $roomtile-name-color; cursor: pointer; - text-decoration: underline; } -.mx_TopUnreadMessagesBar_scrollUp img { - padding-left: 10px; - padding-right: 31px; - vertical-align: middle; -} - -.mx_TopUnreadMessagesBar_scrollUp span { - opacity: 0.5; -} - -.mx_TopUnreadMessagesBar_close { - float: right; - padding-right: 14px; - padding-top: 3px; - cursor: pointer; -} - -.mx_MatrixChat_useCompactLayout { - .mx_TopUnreadMessagesBar { - padding-top: 4px; - padding-bottom: 4px; - } +.mx_TopUnreadMessagesBar_scrollUp:before { + content: ""; + position: absolute; + width: 38px; + height: 38px; + mask: url('../../img/icon-jump-to-first-unread.svg'); + mask-repeat: no-repeat; + mask-position: 9px 13px; + background: $roomtile-name-color; } diff --git a/res/img/icon-jump-to-first-unread.svg b/res/img/icon-jump-to-first-unread.svg new file mode 100644 index 00000000000..652ccec20d2 --- /dev/null +++ b/res/img/icon-jump-to-first-unread.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/src/Presence.js b/src/Presence.js index b1e85e4bc73..849efdef1c0 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -17,21 +17,33 @@ limitations under the License. const MatrixClientPeg = require("./MatrixClientPeg"); const dis = require("./dispatcher"); +import Timer from './utils/Timer'; // Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins const PRESENCE_STATES = ["online", "offline", "unavailable"]; class Presence { + + constructor() { + this._activitySignal = null; + this._unavailableTimer = null; + 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() { + this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); + // the user_activity_start action starts the timer + this._dispatcherRef = dis.register(this._onAction); + while (this._unavailableTimer) { + try { + await this._unavailableTimer.finished(); + this.setState("unavailable"); + } catch(e) { /* aborted, stop got called */ } } } @@ -39,13 +51,14 @@ 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; + } + if (this._unavailableTimer) { + this._unavailableTimer.abort(); + this._unavailableTimer = null; } - this.state = undefined; } /** @@ -56,21 +69,25 @@ class Presence { return this.state; } + _onAction(payload) { + if (payload.action === 'user_activity') { + this.setState("online"); + this._unavailableTimer.restart(); + } + } + /** * 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 +95,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 c628ab41861..4e3667274c3 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -15,32 +15,72 @@ limitations under the License. */ import dis from './dispatcher'; +import Timer from './utils/Timer'; -const MIN_DISPATCH_INTERVAL_MS = 500; -const CURRENTLY_ACTIVE_THRESHOLD_MS = 2000; +// important this is larger than the timeouts of timers +// used with UserActivity.timeWhileActive, +// 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; /** * 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) + * and starts/stops attached timers while the user is active. */ class UserActivity { + constructor() { + this._attachedTimers = []; + this._activityTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); + this._onUserActivity = this._onUserActivity.bind(this); + this._onDocumentBlurred = this._onDocumentBlurred.bind(this); + this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this); + this.lastScreenX = 0; + this.lastScreenY = 0; + } + + /** + * Runs the given timer while the user is active, aborting when the user becomes inactive. + * 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. + */ + timeWhileActive(timer) { + // important this happens first + const index = this._attachedTimers.indexOf(timer); + if (index === -1) { + this._attachedTimers.push(timer); + // remove when done or aborted + timer.finished().finally(() => { + const index = this._attachedTimers.indexOf(timer); + if (index !== -1) { // should never be -1 + this._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.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; + document.addEventListener("visibilitychange", this._onPageVisibilityChanged); + document.addEventListener("blur", this._onDocumentBlurred); + document.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.bind(this), + window.addEventListener('wheel', this._onUserActivity, { passive: true, capture: true }); - this.lastActivityAtTs = new Date().getTime(); - this.lastDispatchAtTs = 0; - this.activityEndTimer = undefined; } /** @@ -50,8 +90,12 @@ 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 }); + + document.removeEventListener("visibilitychange", this._onPageVisibilityChanged); + document.removeEventListener("blur", this._onDocumentBlurred); + document.removeEventListener("focus", this._onUserActivity); } /** @@ -60,10 +104,22 @@ 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(); + } + + _onPageVisibilityChanged(e) { + if (document.visibilityState === "hidden") { + this._activityTimeout.abort(); + } else { + this._onUserActivity(e); + } + } + + _onDocumentBlurred() { + this._activityTimeout.abort(); } - _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 +129,20 @@ 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; + dis.dispatch({action: 'user_activity'}); + if (!this._activityTimeout.isRunning()) { + this._activityTimeout.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()); } else { - this.activityEndTimer = setTimeout(this._onActivityEndTimer.bind(this), targetTime - now); + this._activityTimeout.restart(); } } } + module.exports = new UserActivity(); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 1f770c66c64..bce24ddc8e3 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -1794,14 +1794,10 @@ module.exports = React.createClass({ let topUnreadMessagesBar = null; if (this.state.showTopUnreadMessagesBar) { const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar'); - topUnreadMessagesBar = ( -
- -
- ); + topUnreadMessagesBar = (); } const statusBarAreaClass = classNames( "mx_RoomView_statusArea", @@ -1838,9 +1834,11 @@ module.exports = React.createClass({
{ auxPanel } - { topUnreadMessagesBar } - { messagePanel } - { searchResultsPanel } +
+ { topUnreadMessagesBar } + { messagePanel } + { searchResultsPanel } +
diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index f5dc6c7ef4a..ab10ec4acae 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -33,9 +33,13 @@ const ObjectUtils = require('../../ObjectUtils'); const Modal = require("../../Modal"); const UserActivity = require("../../UserActivity"); import { KeyCode } from '../../Keyboard'; +import Timer from '../../utils/Timer'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; +const READ_MARKER_INVIEW_THRESHOLD_MS = 1 * 1000; +const READ_MARKER_OUTOFVIEW_THRESHOLD_MS = 30 * 1000; +const READ_RECEIPT_INTERVAL_MS = 500; const DEBUG = false; @@ -188,6 +192,14 @@ var TimelinePanel = React.createClass({ this.lastRRSentEventId = undefined; this.lastRMSentEventId = undefined; + if (this.props.manageReadReceipts) { + this.updateReadReceiptOnUserActivity(); + } + if (this.props.manageReadMarkers) { + this.updateReadMarkerOnUserActivity(); + } + + this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); @@ -254,6 +266,14 @@ var TimelinePanel = React.createClass({ // // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; + if (this._readReceiptActivityTimer) { + this._readReceiptActivityTimer.abort(); + this._readReceiptActivityTimer = null; + } + if (this._readMarkerActivityTimer) { + this._readMarkerActivityTimer.abort(); + this._readMarkerActivityTimer = null; + } dis.unregister(this.dispatcherRef); @@ -362,30 +382,25 @@ var TimelinePanel = React.createClass({ } if (this.props.manageReadMarkers) { + const rmPosition = this.getReadMarkerPosition(); // we hide the read marker when it first comes onto the screen, but if // it goes back off the top of the screen (presumably because the user // clicks on the 'jump to bottom' button), we need to re-enable it. - if (this.getReadMarkerPosition() < 0) { + if (rmPosition < 0) { this.setState({readMarkerVisible: true}); } + + // if read marker position goes between 0 and -1/1, + // (and user is active), switch timeout + const timeout = this._readMarkerTimeout(rmPosition); + // NO-OP when timeout already has set to the given value + this._readMarkerActivityTimer.changeTimeout(timeout); } }, 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(); - this.updateReadMarker(); - break; - case 'ignore_state_changed': - this.forceUpdate(); - break; + if (payload.action === 'ignore_state_changed') { + this.forceUpdate(); } }, @@ -531,6 +546,38 @@ var TimelinePanel = React.createClass({ this.setState({clientSyncState: state}); }, + _readMarkerTimeout(readMarkerPosition) { + return readMarkerPosition === 0 ? + READ_MARKER_INVIEW_THRESHOLD_MS : + READ_MARKER_OUTOFVIEW_THRESHOLD_MS; + }, + + updateReadMarkerOnUserActivity: async function() { + const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition()); + this._readMarkerActivityTimer = new Timer(initialTimeout); + + while (this._readMarkerActivityTimer) { //unset on unmount + UserActivity.timeWhileActive(this._readMarkerActivityTimer); + try { + await this._readMarkerActivityTimer.finished(); + } catch(e) { continue; /* aborted */ } + // outside of try/catch to not swallow errors + this.updateReadMarker(); + } + }, + + updateReadReceiptOnUserActivity: async function() { + this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); + while (this._readReceiptActivityTimer) { //unset on unmount + UserActivity.timeWhileActive(this._readReceiptActivityTimer); + try { + await this._readReceiptActivityTimer.finished(); + } catch(e) { continue; /* aborted */ } + // outside of try/catch to not swallow errors + this.sendReadReceipt(); + } + }, + sendReadReceipt: function() { if (!this.refs.messagePanel) return; if (!this.props.manageReadReceipts) return; @@ -634,10 +681,11 @@ var TimelinePanel = React.createClass({ // of the screen, so move the marker down to the bottom of the screen. updateReadMarker: function() { if (!this.props.manageReadMarkers) return; - if (this.getReadMarkerPosition() !== 0) { + if (this.getReadMarkerPosition() === 1) { + // the read marker is at an event below the viewport, + // we don't want to rewind it. return; } - // 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. @@ -654,7 +702,6 @@ var TimelinePanel = React.createClass({ if (lastDisplayedIndex === null) { return; } - const lastDisplayedEvent = this.state.events[lastDisplayedIndex]; this._setReadMarker(lastDisplayedEvent.getId(), lastDisplayedEvent.getTs()); @@ -749,7 +796,6 @@ var TimelinePanel = React.createClass({ this._loadTimeline(this.state.readMarkerEventId, 0, 1/3); }, - /* update the read-up-to marker to match the read receipt */ forgetReadMarker: function() { @@ -822,15 +868,12 @@ var TimelinePanel = React.createClass({ canJumpToReadMarker: function() { // 1. Do not show jump bar if neither the RM nor the RR are set. - // 2. Only show jump bar if RR !== RM. If they are the same, there are only fully - // read messages and unread messages. We already have a badge count and the bottom - // bar to jump to "live" when we have unread messages. // 3. We want to show the bar if the read-marker is off the top of the screen. // 4. Also, if pos === null, the event might not be paginated - show the unread bar const pos = this.getReadMarkerPosition(); - return this.state.readMarkerEventId !== null && // 1. - this.state.readMarkerEventId !== this._getCurrentReadReceipt() && // 2. + const ret = this.state.readMarkerEventId !== null && // 1. (pos < 0 || pos === null); // 3., 4. + return ret; }, /** @@ -917,7 +960,6 @@ var TimelinePanel = React.createClass({ } this.sendReadReceipt(); - this.updateReadMarker(); }); }; diff --git a/src/components/views/rooms/TopUnreadMessagesBar.js b/src/components/views/rooms/TopUnreadMessagesBar.js index b2648e55a9c..ed04ce594db 100644 --- a/src/components/views/rooms/TopUnreadMessagesBar.js +++ b/src/components/views/rooms/TopUnreadMessagesBar.js @@ -21,6 +21,8 @@ const React = require('react'); import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; +import {formatCount} from '../../../utils/FormattingUtils'; + const sdk = require('../../../index'); module.exports = React.createClass({ @@ -28,28 +30,15 @@ module.exports = React.createClass({ propTypes: { onScrollUpClick: PropTypes.func, - onCloseClick: PropTypes.func, }, render: function() { return (
- - { _t("Jump to first unread message.") } + title={_t('Jump to first unread message.')} + onClick={this.props.onScrollUpClick}> -
); }, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4ab130e923e..c137253a43a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -604,7 +604,6 @@ "Stickerpack": "Stickerpack", "Hide Stickers": "Hide Stickers", "Show Stickers": "Show Stickers", - "Scroll to unread messages": "Scroll to unread messages", "Jump to first unread message.": "Jump to first unread message.", "Invalid alias format": "Invalid alias format", "'%(alias)s' is not a valid format for an alias": "'%(alias)s' is not a valid format for an alias", diff --git a/src/utils/Timer.js b/src/utils/Timer.js new file mode 100644 index 00000000000..aeac0887c91 --- /dev/null +++ b/src/utils/Timer.js @@ -0,0 +1,123 @@ +/* +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. +*/ + +/** +A countdown timer, exposing a promise api. +A timer starts in a non-started state, +and needs to be started by calling `start()`` on it first. + +Timers can be `abort()`-ed which makes the promise reject prematurely. + +Once a timer is finished or aborted, it can't be started again +(because the promise should not be replaced). Instead, create +a new one through `clone()` or `cloneIfRun()`. +*/ +export default class Timer { + + constructor(timeout) { + this._timeout = timeout; + this._onTimeout = this._onTimeout.bind(this); + this._setNotStarted(); + } + + _setNotStarted() { + this._timerHandle = null; + this._startTs = null; + this._promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + }).finally(() => { + this._timerHandle = null; + }); + } + + _onTimeout() { + const now = Date.now(); + const elapsed = now - this._startTs; + if (elapsed >= this._timeout) { + this._resolve(); + this._setNotStarted(); + } else { + const delta = this._timeout - elapsed; + this._timerHandle = setTimeout(this._onTimeout, delta); + } + } + + changeTimeout(timeout) { + if (timeout === this._timeout) { + return; + } + const isSmallerTimeout = timeout < this._timeout; + this._timeout = timeout; + if (this.isRunning() && isSmallerTimeout) { + clearTimeout(this._timerHandle); + this._onTimeout(); + } + } + + /** + * if not started before, starts the timer. + */ + start() { + if (!this.isRunning()) { + this._startTs = Date.now(); + this._timerHandle = setTimeout(this._onTimeout, this._timeout); + } + return this; + } + + /** + * (re)start the timer. If it's running, reset the timeout. If not, start it. + */ + restart() { + if (this.isRunning()) { + // don't clearTimeout here as this method + // can be called in fast succession, + // instead just take note and compare + // when the already running timeout expires + this._startTs = Date.now(); + return this; + } else { + return this.start(); + } + } + + /** + * if the timer is running, abort it, + * and reject the promise for this timer. + */ + abort() { + if (this.isRunning()) { + clearTimeout(this._timerHandle); + this._reject(new Error("Timer was aborted.")); + this._setNotStarted(); + } + return this; + } + + /** + *promise that will resolve when the timer elapses, + *or is rejected when abort is called + *@return {Promise} + */ + finished() { + return this._promise; + } + + isRunning() { + return this._timerHandle !== null; + } +}