diff --git a/src/component-index.js b/src/component-index.js index 63d2f3c39d..869d60f204 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -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'); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index cfa4735481..d3d60c2af5 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -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(
  • ); } - 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
  • 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; - hr =
    ; + if (visible) { + hr =
    ; + } return ( -
  • {hr}
  • diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8a3dca32a9..e774121153 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -60,8 +60,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. @@ -90,6 +90,8 @@ module.exports = React.createClass({ // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, + showTopUnreadMessagesBar: false, + auxPanelMaxHeight: undefined, } }, @@ -552,6 +554,7 @@ module.exports = React.createClass({ atEndOfLiveTimeline: false, }); } + this._updateTopUnreadMessagesBar(); }, onDragOver: function(ev) { @@ -875,6 +878,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. // @@ -1243,8 +1270,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 = ( +
    + +
    + ); + } + return (
    { auxPanel } + { topUnreadMessagesBar } { messagePanel } { searchResultsPanel }
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 044ef48687..030904cb57 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -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 diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index adad8bc060..c8e5482d15 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -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; diff --git a/src/components/views/rooms/TopUnreadMessagesBar.js b/src/components/views/rooms/TopUnreadMessagesBar.js new file mode 100644 index 0000000000..a1e24e2eac --- /dev/null +++ b/src/components/views/rooms/TopUnreadMessagesBar.js @@ -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 ( +
    +
    + Scroll to unread messages + Unread messages +
    + Close +
    + ); + }, +}); +