Merge branch 'rav/roomview_works' into develop

Implementation of new read-marker semantics (PR #160).
This commit is contained in:
Richard van der Hoff 2016-02-24 17:26:34 +00:00
parent 1959b03104
commit 10b55036f9
6 changed files with 359 additions and 63 deletions

View file

@ -89,6 +89,7 @@ module.exports.components['views.rooms.RoomTile'] = require('./components/views/
module.exports.components['views.rooms.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList');
module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile');
module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar');
module.exports.components['views.rooms.TopUnreadMessagesBar'] = require('./components/views/rooms/TopUnreadMessagesBar');
module.exports.components['views.rooms.UserTile'] = require('./components/views/rooms/UserTile');
module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
var React = require('react');
var ReactDOM = require("react-dom");
var sdk = require('../../index');
/* (almost) stateless UI component which builds the event tiles in the room timeline.
@ -35,6 +36,9 @@ module.exports = React.createClass({
// event after which we should show a read marker
readMarkerEventId: React.PropTypes.string,
// whether the read marker should be visible
readMarkerVisible: React.PropTypes.bool,
// the userid of our user. This is used to suppress the read marker
// for pending messages.
ourUserId: React.PropTypes.string,
@ -91,6 +95,34 @@ module.exports = React.createClass({
return this.refs.scrollPanel.getScrollState();
},
// returns one of:
//
// null: there is no read marker
// -1: read marker is above the window
// 0: read marker is within the window
// +1: read marker is below the window
getReadMarkerPosition: function() {
var readMarker = this.refs.readMarkerNode;
var messageWrapper = this.refs.scrollPanel;
if (!readMarker || !messageWrapper) {
return null;
}
var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
var readMarkerRect = readMarker.getBoundingClientRect();
// the read-marker pretends to have zero height when it is actually
// two pixels high; +2 here to account for that.
if (readMarkerRect.bottom + 2 < wrapperRect.top) {
return -1;
} else if (readMarkerRect.top < wrapperRect.bottom) {
return 0;
} else {
return 1;
}
},
/* jump to the bottom of the content.
*/
scrollToBottom: function() {
@ -103,7 +135,7 @@ module.exports = React.createClass({
*
* pixelOffset gives the number of pixels between the bottom of the node
* and the bottom of the container. If undefined, it will put the node
* in the middle of the container.
* 1/3 of the way down of the container.
*/
scrollToEvent: function(eventId, pixelOffset) {
if (this.refs.scrollPanel) {
@ -166,15 +198,17 @@ module.exports = React.createClass({
ret.push(<li key={eventId} data-scroll-token={eventId}/>);
}
if (eventId == this.props.readMarkerEventId && i < lastShownEventIndex) {
// suppress the read marker if the next event is sent by us; this
// is a nonsensical and temporary situation caused by the delay between
// us sending a message and receiving the synthesized receipt.
var nextEvent = this.props.events[i+1];
if (nextEvent.sender && nextEvent.sender.userId != this.props.ourUserId) {
ret.push(this._getReadMarkerTile());
readMarkerVisible = true;
if (eventId == this.props.readMarkerEventId) {
var visible = this.props.readMarkerVisible;
// if the read marker comes at the end of the timeline, we don't want
// to show it, but we still want to create the <li/> for it so that the
// algorithms which depend on its position on the screen aren't confused.
if (i >= lastShownEventIndex) {
visible = false;
}
ret.push(this._getReadMarkerTile(visible));
readMarkerVisible = visible;
} else if (eventId == this.currentReadMarkerEventId && !this.currentGhostEventId) {
// there is currently a read-up-to marker at this point, but no
// more. Show an animation of it disappearing.
@ -234,14 +268,16 @@ module.exports = React.createClass({
return ret;
},
_getReadMarkerTile: function() {
_getReadMarkerTile: function(visible) {
var hr;
if (visible) {
hr = <hr className="mx_RoomView_myReadMarker"
style={{opacity: 1, width: '99%'}}
/>;
}
return (
<li key="_readupto"
<li key="_readupto" ref="readMarkerNode"
className="mx_RoomView_myReadMarker_container">
{hr}
</li>

View file

@ -64,8 +64,8 @@ module.exports = React.createClass({
eventId: React.PropTypes.string,
// where to position the event given by eventId, in pixels from the
// bottom of the viewport. If not given, will try to put the event in the
// middle of the viewprt.
// bottom of the viewport. If not given, will try to put the event
// 1/3 of the way down the viewport.
eventPixelOffset: React.PropTypes.number,
// ID of an event to highlight. If undefined, no event will be highlighted.
@ -94,6 +94,8 @@ module.exports = React.createClass({
// 'scroll to bottom' knob, among a couple of other things.
atEndOfLiveTimeline: true,
showTopUnreadMessagesBar: false,
auxPanelMaxHeight: undefined,
}
},
@ -556,6 +558,7 @@ module.exports = React.createClass({
atEndOfLiveTimeline: false,
});
}
this._updateTopUnreadMessagesBar();
},
onDragOver: function(ev) {
@ -879,6 +882,30 @@ module.exports = React.createClass({
this.refs.messagePanel.jumpToLiveTimeline();
},
// jump up to wherever our read marker is
jumpToReadMarker: function() {
this.refs.messagePanel.jumpToReadMarker();
},
// update the read marker to match the read-receipt
forgetReadMarker: function() {
this.refs.messagePanel.forgetReadMarker();
},
// decide whether or not the top 'unread messages' bar should be shown
_updateTopUnreadMessagesBar: function() {
if (!this.refs.messagePanel)
return;
var pos = this.refs.messagePanel.getReadMarkerPosition();
// we want to show the bar if the read-marker is off the top of the
// screen.
var showBar = (pos < 0);
this.setState({showTopUnreadMessagesBar: showBar});
},
// get the current scroll position of the room, so that it can be
// restored when we switch back to it.
//
@ -1247,8 +1274,22 @@ module.exports = React.createClass({
eventId={this.props.eventId}
eventPixelOffset={this.props.eventPixelOffset}
onScroll={ this.onMessageListScroll }
onReadMarkerUpdated={ this._updateTopUnreadMessagesBar }
/>);
var topUnreadMessagesBar = null;
if (this.state.showTopUnreadMessagesBar) {
var TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar');
topUnreadMessagesBar = (
<div className="mx_RoomView_topUnreadMessagesBar">
<TopUnreadMessagesBar
onScrollUpClick={this.jumpToReadMarker}
onCloseClick={this.forgetReadMarker}
/>
</div>
);
}
return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
@ -1264,6 +1305,7 @@ module.exports = React.createClass({
(myMember && myMember.membership === "join") ? this.onLeaveClick : null
} />
{ auxPanel }
{ topUnreadMessagesBar }
{ messagePanel }
{ searchResultsPanel }
<div className="mx_RoomView_statusArea">

View file

@ -328,13 +328,13 @@ module.exports = React.createClass({
// pixelOffset gives the number of pixels between the bottom of the node
// and the bottom of the container. If undefined, it will put the node
// in the middle of the container.
// 1/3 of the way down the container.
scrollToToken: function(scrollToken, pixelOffset) {
var scrollNode = this._getScrollNode();
// default to the middle
// default to 1/3 of the way down
if (pixelOffset === undefined) {
pixelOffset = scrollNode.clientHeight / 2;
pixelOffset = (scrollNode.clientHeight * 2)/ 3;
}
// save the desired scroll state. It's important we do this here rather

View file

@ -29,6 +29,11 @@ var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
var TIMELINE_CAP = 1000; // the most events to show in a timeline
// consider that the user remains "active" for this many milliseconds after a
// user_activity event (and thus don't make the read-marker visible on new
// events)
var CONSIDER_USER_ACTIVE_FOR_MS = 500;
var DEBUG = false;
if (DEBUG) {
@ -43,7 +48,7 @@ if (DEBUG) {
*
* Also responsible for handling and sending read receipts.
*/
module.exports = React.createClass({
var TimelinePanel = React.createClass({
displayName: 'TimelinePanel',
propTypes: {
@ -63,20 +68,40 @@ module.exports = React.createClass({
eventId: React.PropTypes.string,
// where to position the event given by eventId, in pixels from the
// bottom of the viewport. If not given, will try to put the event in the
// middle of the viewprt.
// bottom of the viewport. If not given, will try to put the event
// 1/3 of the way down the viewport.
eventPixelOffset: React.PropTypes.number,
// callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func,
// callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: React.PropTypes.func,
},
statics: {
// a map from room id to read marker event ID
roomReadMarkerMap: {},
// a map from room id to read marker event timestamp
roomReadMarkerTsMap: {},
},
getInitialState: function() {
var initialReadMarker =
TimelinePanel.roomReadMarkerMap[this.props.room.roomId]
|| this._getCurrentReadReceipt();
return {
events: [],
timelineLoading: true, // track whether our room timeline is loading
canBackPaginate: true,
readMarkerEventId: this._getCurrentReadReceipt(),
// start with the read-marker visible, so that we see its animated
// disappearance when swtitching into the room.
readMarkerVisible: true,
readMarkerEventId: initialReadMarker,
};
},
@ -84,9 +109,10 @@ module.exports = React.createClass({
debuglog("TimelinePanel: mounting");
this.last_rr_sent_event_id = undefined;
this._resetActivityTimer();
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
this._initTimeline(this.props);
@ -116,7 +142,6 @@ module.exports = React.createClass({
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("Room.timeline", this.onRoomTimeline);
client.removeListener("Room.receipt", this.onRoomReceipt);
client.removeListener("Room.redaction", this.onRoomRedaction);
}
},
@ -136,9 +161,26 @@ module.exports = React.createClass({
});
},
onMessageListScroll: function () {
if (this.props.onScroll) {
this.props.onScroll();
}
// 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) {
this.setState({readMarkerVisible: true});
}
},
onAction: function(payload) {
switch (payload.action) {
case 'user_activity':
this._resetActivityTimer();
// fall-through!
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
@ -146,10 +188,15 @@ module.exports = React.createClass({
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt();
this.updateReadMarker();
break;
}
},
_resetActivityTimer: function() {
this.user_last_active = Date.now();
},
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
// ignore events for other rooms
if (room !== this.props.room) return;
@ -158,6 +205,26 @@ module.exports = React.createClass({
// updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return;
if (!this.refs.messagePanel) return;
// when a new event arrives when the user is not watching the window, but the
// window is in its auto-scroll mode, make sure the read marker is visible.
//
// We consider the user to be watching the window if they performed an action
// less than CONSIDER_USER_ACTIVE_FOR_MS ago.
//
// We ignore events we have sent ourselves; we don't want to see the
// read-marker when a remote echo of an event we have just sent takes
// more than CONSIDER_USER_ACTIVE_FOR_MS.
//
var myUserId = MatrixClientPeg.get().credentials.userId;
var sender = ev.sender ? ev.sender.userId : null;
var activity_age = Date.now() - this.user_last_active;
if (sender != myUserId && this.refs.messagePanel.getScrollState().stuckAtBottom
&& activity_age > CONSIDER_USER_ACTIVE_FOR_MS) {
this.setState({readMarkerVisible: true});
}
// tell the messagepanel to go paginate itself. This in turn will cause
// onMessageListFillRequest to be called, which will call
// _onTimelineUpdated, which will update the state with the new event -
@ -168,37 +235,6 @@ module.exports = React.createClass({
}
},
onRoomReceipt: function(receiptEvent, room) {
if (room !== this.props.room)
return;
// the received event may or may not be for our user; but it turns out
// to be easier to do the processing anyway than to figure out if it
// is.
var oldReadMarker = this.state.readMarkerEventId;
var newReadMarker = this._getCurrentReadReceipt();
if (newReadMarker == oldReadMarker) {
return;
}
// suppress the animation when moving forward over an event which was sent
// by us; the original RM will have been suppressed so we don't want to show
// the animation either.
var oldReadMarkerIndex = this._indexForEventId(oldReadMarker);
if (oldReadMarkerIndex + 1 < this.state.events.length) {
var myUserId = MatrixClientPeg.get().credentials.userId;
var nextEvent = this.state.events[oldReadMarkerIndex + 1];
if (nextEvent.sender && nextEvent.sender.userId == myUserId) {
oldReadMarker = undefined;
}
}
this.setState({
readMarkerEventId: newReadMarker,
});
},
onRoomRedaction: function(ev, room) {
if (this.unmounted) return;
@ -234,7 +270,9 @@ module.exports = React.createClass({
return;
}
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
var lastReadEventIndex = this._getLastDisplayedEventIndex({
ignoreOwn: true
});
if (lastReadEventIndex === null) return;
var lastReadEvent = this.state.events[lastReadEventIndex];
@ -251,6 +289,45 @@ module.exports = 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() {
if (this.getReadMarkerPosition() !== 0) {
return;
}
var currentIndex = this._indexForEventId(this.state.readMarkerEventId);
// 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.
//
// But ignore local echoes for this - they have a temporary event ID
// and we'll get confused when their ID changes and we can't figure out
// where the RM is pointing to. The read marker will be invisible for
// now anyway, so this doesn't really matter.
var lastDisplayedIndex = this._getLastDisplayedEventIndex({
allowPartial: true,
ignoreEchoes: true,
});
if (lastDisplayedIndex === null) {
return;
}
var lastDisplayedEvent = this.state.events[lastDisplayedIndex];
this._setReadMarker(lastDisplayedEvent.getId(),
lastDisplayedEvent.getTs());
// the read-marker should become invisible, so that if the user scrolls
// down, they don't see it.
if(this.state.readMarkerVisible) {
this.setState({
readMarkerVisible: false,
});
}
},
/* jump down to the bottom of this room, where new events are arriving
*/
jumpToLiveTimeline: function() {
@ -268,6 +345,35 @@ module.exports = React.createClass({
}
},
/* scroll to show the read-up-to marker
*/
jumpToReadMarker: function() {
if (!this.state.readMarkerEventId)
return;
if (!this.refs.messagePanel)
return;
this.refs.messagePanel.scrollToEvent(this.state.readMarkerEventId);
},
/* update the read-up-to marker to match the read receipt
*/
forgetReadMarker: function() {
var rmId = this._getCurrentReadReceipt();
// see if we know the timestamp for the rr event
var tl = this.props.room.getTimelineForEvent(rmId);
var rmTs;
if (tl) {
var event = tl.getEvents().find((e) => { return e.getId() == rmId });
if (event) {
rmTs = event.getTs();
}
}
this._setReadMarker(rmId, rmTs);
},
/* return true if the content is fully scrolled down and we are
* at the end of the live timeline.
*/
@ -289,6 +395,33 @@ module.exports = React.createClass({
return this.refs.messagePanel.getScrollState();
},
// returns one of:
//
// null: there is no read marker
// -1: read marker is above the window
// 0: read marker is visible
// +1: read marker is below the window
getReadMarkerPosition: function() {
if (!this.refs.messagePanel) { return null; }
var ret = this.refs.messagePanel.getReadMarkerPosition();
if (ret !== null) {
return ret;
}
// the messagePanel doesn't know where the read marker is.
// if we know the timestamp of the read marker, make a guess based on that.
var rmTs = TimelinePanel.roomReadMarkerTsMap[this.props.room.roomId];
if (rmTs && this.state.events) {
if (rmTs < this.state.events[0].getTs()) {
return -1;
} else {
return 1;
}
}
return null;
},
_initTimeline: function(props) {
var initialEvent = props.eventId;
var pixelOffset = props.eventPixelOffset;
@ -304,7 +437,7 @@ module.exports = React.createClass({
*
* @param {number?} pixelOffset offset to position the given event at
* (pixels from the bottom of the view). If undefined, will put the
* event in the middle of the view.
* event 1/3 of the way down the view.
*
* returns a promise which will resolve when the load completes.
*/
@ -343,6 +476,7 @@ module.exports = React.createClass({
}
this.sendReadReceipt();
this.updateReadMarker();
});
});
},
@ -369,16 +503,27 @@ module.exports = React.createClass({
return null;
},
_getLastDisplayedEventIndexIgnoringOwn: function() {
_getLastDisplayedEventIndex: function(opts) {
opts = opts || {};
var ignoreOwn = opts.ignoreOwn || false;
var ignoreEchoes = opts.ignoreEchoes || false;
var allowPartial = opts.allowPartial || false;
var messagePanel = this.refs.messagePanel;
if (messagePanel === undefined) return null;
var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect();
var myUserId = MatrixClientPeg.get().credentials.userId;
for (var i = this.state.events.length-1; i >= 0; --i) {
var ev = this.state.events[i];
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
if (ignoreOwn && ev.sender && ev.sender.userId == myUserId) {
continue;
}
// local echoes have a fake event ID
if (ignoreEchoes && ev.status) {
continue;
}
@ -386,8 +531,8 @@ module.exports = React.createClass({
if (!node) continue;
var boundingRect = node.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
if ((allowPartial && boundingRect.top < wrapperRect.bottom) ||
(!allowPartial && boundingRect.bottom < wrapperRect.bottom)) {
return i;
}
}
@ -412,6 +557,27 @@ module.exports = React.createClass({
return this.props.room.getEventReadUpTo(myUserId, ignoreSynthesized);
},
_setReadMarker: function(eventId, eventTs) {
if (TimelinePanel.roomReadMarkerMap[this.props.room.roomId] == eventId) {
// don't update the state (and cause a re-render) if there is
// no change to the RM.
return;
}
// ideally we'd sync these via the server, but for now just stash them
// in a map.
TimelinePanel.roomReadMarkerMap[this.props.room.roomId] = eventId;
// in order to later figure out if the read marker is
// above or below the visible timeline, we stash the timestamp.
TimelinePanel.roomReadMarkerTsMap[this.props.room.roomId] = eventTs;
// run the render cycle before calling the callback, so that
// getReadMarkerPosition() returns the right thing.
this.setState({
readMarkerEventId: eventId,
}, this.props.onReadMarkerUpdated);
},
render: function() {
var MessagePanel = sdk.getComponent("structures.MessagePanel");
@ -452,12 +618,15 @@ module.exports = React.createClass({
events={ this.state.events }
highlightedEventId={ this.props.highlightedEventId }
readMarkerEventId={ this.state.readMarkerEventId }
readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
onScroll={ this.props.onScroll }
onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest }
/>
);
},
});
module.exports = TimelinePanel;

View file

@ -0,0 +1,48 @@
/*
Copyright 2016 OpenMarket 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.
*/
'use strict';
var React = require('react');
var sdk = require('../../../index');
module.exports = React.createClass({
displayName: 'TopUnreadMessagesBar',
propTypes: {
onScrollUpClick: React.PropTypes.func,
onCloseClick: React.PropTypes.func,
},
render: function() {
return (
<div className="mx_TopUnreadMessagesBar">
<div className="mx_TopUnreadMessagesBar_scrollUp"
onClick={this.props.onScrollUpClick}>
<img src="img/scrollup.svg" width="24" height="24"
alt="Scroll to unread messages"
title="Scroll to unread messages"/>
Unread messages
</div>
<img className="mx_TopUnreadMessagesBar_close"
src="img/cancel.svg" width="18" height="18"
alt="Close" title="Close"
onClick={this.props.onCloseClick} />
</div>
);
},
});