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 #2322

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
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