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

Redesign: restyle jump to first unread message & rework read marker logic (rebased) #2345

Merged
merged 8 commits into from
Dec 12, 2018
8 changes: 6 additions & 2 deletions res/css/structures/_RoomView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 19 additions & 29 deletions res/css/views/rooms/_TopUnreadMessagesBar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might want to double check this doesn't get layered weirdly with widgets or dialogs. I can't remember what their zindex is, but historically we've had problems with layering.

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;
}
16 changes: 16 additions & 0 deletions res/img/icon-jump-to-first-unread.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 36 additions & 47 deletions src/Presence.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,48 @@ 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 */ }
}
}

/**
* 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;
}

/**
Expand All @@ -56,64 +69,40 @@ 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;

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();
116 changes: 81 additions & 35 deletions src/UserActivity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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
Expand All @@ -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();
Loading