From c3692378fabe2a5c4eb651dcb40d33ffb5b4e5a6 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 10 Feb 2016 11:03:46 +0000 Subject: [PATCH 1/3] Factor a stateless messagepanel out from RoomView --- src/component-index.js | 1 + src/components/structures/MessagePanel.js | 264 ++++++++++++++++++++++ src/components/structures/RoomView.js | 180 ++------------- 3 files changed, 287 insertions(+), 158 deletions(-) create mode 100644 src/components/structures/MessagePanel.js diff --git a/src/component-index.js b/src/component-index.js index 5a37a98913..a0abd33348 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -31,6 +31,7 @@ module.exports.components['structures.login.Login'] = require('./components/stru module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); +module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel'); module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar'); module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js new file mode 100644 index 0000000000..d0c12eac31 --- /dev/null +++ b/src/components/structures/MessagePanel.js @@ -0,0 +1,264 @@ +/* +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. +*/ + +var React = require('react'); +var sdk = require('../../index'); + +/* stateless UI component which builds the event tiles in the room timeline. + */ +module.exports = React.createClass({ + displayName: 'MessagePanel', + + propTypes: { + // true to give the component a 'display: hidden' style. + hidden: React.PropTypes.bool, + + // the list of MatrixEvents to display + events: React.PropTypes.array.isRequired, + + // ID of an event to highlight. If undefined, no event will be highlighted. + highlightedEventId: React.PropTypes.string, + + // event after which we should show a read marker + readMarkerEventId: React.PropTypes.string, + + // event after which we should show an animating disappearance of a + // read marker + readMarkerGhostEventId: React.PropTypes.string, + + // the userid of our user. This is used to suppress the read marker + // for pending messages. + ourUserId: React.PropTypes.string, + + // true to suppress the date at the start of the timeline + suppressFirstDateSeparator: React.PropTypes.bool, + + // true if updates to the event list should cause the scroll panel to + // scroll down when we are at the bottom of the window. See ScrollPanel + // for more details. + stickyBottom: React.PropTypes.bool, + + // callback to determine if a user is the magic freeswitch conference + // user. Takes one parameter, which is a user id. Should return true if + // the user is the conference user. + isConferenceUser: React.PropTypes.func, + + // callback which is called when the panel is scrolled. + onScroll: React.PropTypes.func, + + // callback which is called when more content is needed. + onFillRequest: React.PropTypes.func, + }, + + /* get the DOM node representing the given event */ + getNodeForEventId: function(eventId) { + if (!this.eventNodes) { + return undefined; + } + + return this.eventNodes[eventId]; + }, + + /* return true if the content is fully scrolled down right now; else false. + */ + isAtBottom: function() { + return this.refs.scrollPanel + && this.refs.scrollPanel.isAtBottom(); + }, + + /* get the current scroll state. See ScrollPanel.getScrollState for + * details. + * + * returns null if we are not mounted. + */ + getScrollState: function() { + if (!this.refs.scrollPanel) { return null; } + return this.refs.scrollPanel.getScrollState(); + }, + + /* jump to the bottom of the content. + */ + scrollToBottom: function() { + if (this.refs.scrollPanel) { + this.refs.scrollPanel.scrollToBottom(); + } + }, + + /* jump to the given event id. + * + * 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. + */ + scrollToEvent: function(eventId, pixelOffset) { + if (this.refs.scrollPanel) { + this.refs.scrollPanel.scrollToToken(eventId, pixelOffset); + } + }, + + /* check the scroll state and send out pagination requests if necessary. + */ + checkFillState: function() { + if (this.refs.scrollPanel) { + this.refs.scrollPanel.checkFillState(); + } + }, + + render: function() { + var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); + return ( + + {this._getEventTiles()} + + ); + }, + + _getEventTiles: function() { + var DateSeparator = sdk.getComponent('messages.DateSeparator'); + var EventTile = sdk.getComponent('rooms.EventTile'); + + var ret = []; + + var prevEvent = null; // the last event we showed + var ghostIndex; + var readMarkerIndex; + for (var i = 0; i < this.props.events.length; i++) { + var mxEv = this.props.events[i]; + + if (!EventTile.haveTileForEvent(mxEv)) { + continue; + } + + if (this.props.isConferenceUser && mxEv.getType() === "m.room.member") { + if (this.props.isConferenceUser(mxEv.getSender()) || + this.props.isConferenceUser(mxEv.getStateKey())) { + continue; // suppress conf user join/parts + } + } + + // now we've decided whether or not to show this message, + // add the read up to marker if appropriate + // doing this here means we implicitly do not show the marker + // if it's at the bottom + // NB. it would be better to decide where the read marker was going + // when the state changed rather than here in the render method, but + // this is where we decide what messages we show so it's the only + // place we know whether we're at the bottom or not. + var mxEvSender = mxEv.sender ? mxEv.sender.userId : null; + if (prevEvent && prevEvent.getId() == this.props.readMarkerEventId) { + // 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. + if (mxEvSender != this.props.ourUserId) { + var hr; + hr = ( +
); + readMarkerIndex = ret.length; + ret.push( +
  • + {hr} +
  • ); + } + } + + // is this a continuation of the previous message? + var continuation = false; + if (prevEvent !== null) { + if (mxEvSender && + prevEvent.sender && + (mxEvSender === prevEvent.sender.userId) && + (mxEv.getType() == prevEvent.getType()) + ) + { + continuation = true; + } + } + + // do we need a date separator since the last event? + var ts1 = mxEv.getTs(); + if ((prevEvent == null && !this.props.suppressFirstDateSeparator) || + (prevEvent != null && + new Date(prevEvent.getTs()).toDateString() + !== new Date(ts1).toDateString())) { + var dateSeparator =
  • ; + ret.push(dateSeparator); + continuation = false; + } + + var last = false; + if (i == this.props.events.length - 1) { + // XXX: we might not show a tile for the last event. + last = true; + } + + var eventId = mxEv.getId(); + var highlight = (eventId == this.props.highlightedEventId); + + // we can't use local echoes as scroll tokens, because their event IDs change. + // Local echos have a send "status". + var scrollToken = mxEv.status ? undefined : eventId; + + ret.push( +
  • + +
  • + ); + + // A read up to marker has died and returned as a ghost! + // Lives in the dom as the ghost of the previous one while it fades away + if (eventId == this.props.readMarkerGhostEventId) { + ghostIndex = ret.length; + } + + prevEvent = mxEv; + } + + // splice the read marker ghost in now that we know whether the read receipt + // is the last element or not, because we only decide as we're going along. + if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) { + var hr; + hr = (
    ); + ret.splice(ghostIndex, 0, ( +
  • + {hr} +
  • + )); + } + + return ret; + }, + + _collectEventNode: function(eventId, node) { + if (this.eventNodes == undefined) this.eventNodes = {}; + this.eventNodes[eventId] = node; + }, +}); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index d68ae35dc8..afe3a5112f 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -19,7 +19,6 @@ limitations under the License. // - Search results component // - Drag and drop // - File uploading - uploadFile() -// - Timeline component (alllll the logic in getEventTiles()) var React = require("react"); var ReactDOM = require("react-dom"); @@ -77,10 +76,6 @@ module.exports = React.createClass({ highlightedEventId: React.PropTypes.string, }, - /* properties in RoomView objects include: - * - * eventNodes: a map from event id to DOM node representing that event - */ getInitialState: function() { var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { @@ -227,7 +222,7 @@ module.exports = React.createClass({ return; } if (eventId) { - this.refs.messagePanel.scrollToToken(eventId, pixelOffset); + this.refs.messagePanel.scrollToEvent(eventId, pixelOffset); } else { this.refs.messagePanel.scrollToBottom(); } @@ -537,10 +532,6 @@ module.exports = React.createClass({ }, componentDidMount: function() { - if (this.refs.messagePanel) { - this._initialiseMessagePanel(); - } - var call = CallHandler.getCallForRoom(this.props.roomId); var callState = call ? call.call_state : "ended"; this.setState({ @@ -585,23 +576,6 @@ module.exports = React.createClass({ ); }, 500), - _initialiseMessagePanel: function() { - var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); - this.refs.messagePanel.initialised = true; - this.updateTint(); - }, - - componentDidUpdate: function() { - // we need to initialise the messagepanel if we've just joined the - // room. TODO: we really really ought to factor out messagepanel to a - // separate component to avoid this ridiculous dance. - if (!this.refs.messagePanel) return; - - if (!this.refs.messagePanel.initialised) { - this._initialiseMessagePanel(); - } - }, - _onTimelineUpdated: function(gotResults) { // we might have switched rooms since the load started - just bin // the results if so. @@ -954,125 +928,6 @@ module.exports = React.createClass({ return ret; }, - getEventTiles: function() { - var DateSeparator = sdk.getComponent('messages.DateSeparator'); - - var ret = []; - var count = 0; - - var EventTile = sdk.getComponent('rooms.EventTile'); - - var prevEvent = null; // the last event we showed - var ghostIndex; - var readMarkerIndex; - for (var i = 0; i < this.state.events.length; i++) { - var mxEv = this.state.events[i]; - - if (!EventTile.haveTileForEvent(mxEv)) { - continue; - } - if (this.props.ConferenceHandler && mxEv.getType() === "m.room.member") { - if (this.props.ConferenceHandler.isConferenceUser(mxEv.getSender()) || - this.props.ConferenceHandler.isConferenceUser(mxEv.getStateKey())) { - continue; // suppress conf user join/parts - } - } - - // now we've decided whether or not to show this message, - // add the read up to marker if appropriate - // doing this here means we implicitly do not show the marker - // if it's at the bottom - // NB. it would be better to decide where the read marker was going - // when the state changed rather than here in the render method, but - // this is where we decide what messages we show so it's the only - // place we know whether we're at the bottom or not. - var self = this; - var mxEvSender = mxEv.sender ? mxEv.sender.userId : null; - if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) { - var hr; - hr = (
    ); - readMarkerIndex = ret.length; - ret.push(
  • {hr}
  • ); - } - - // is this a continuation of the previous message? - var continuation = false; - if (prevEvent !== null) { - if (mxEv.sender && - prevEvent.sender && - (mxEv.sender.userId === prevEvent.sender.userId) && - (mxEv.getType() == prevEvent.getType()) - ) - { - continuation = true; - } - } - - // do we need a date separator since the last event? - var ts1 = mxEv.getTs(); - if ((prevEvent == null && !this.state.canBackPaginate) || - (prevEvent != null && - new Date(prevEvent.getTs()).toDateString() !== new Date(ts1).toDateString())) { - var dateSeparator =
  • ; - ret.push(dateSeparator); - continuation = false; - } - - var last = false; - if (i == this.state.events.length - 1) { - // XXX: we might not show a tile for the last event. - last = true; - } - - var eventId = mxEv.getId(); - var highlight = (eventId == this.props.highlightedEventId); - - // we can't use local echoes as scroll tokens, because their event IDs change. - // Local echos have a send "status". - var scrollToken = mxEv.status ? undefined : eventId; - - ret.push( -
  • - -
  • - ); - - // A read up to marker has died and returned as a ghost! - // Lives in the dom as the ghost of the previous one while it fades away - if (eventId == this.state.readMarkerGhostEventId) { - ghostIndex = ret.length; - } - - prevEvent = mxEv; - } - - // splice the read marker ghost in now that we know whether the read receipt - // is the last element or not, because we only decide as we're going along. - if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) { - var hr; - hr = (
    ); - ret.splice(ghostIndex, 0, ( -
  • {hr}
  • - )); - } - - return ret; - }, - - _collectEventNode: function(eventId, node) { - if (this.eventNodes == undefined) this.eventNodes = {}; - this.eventNodes[eventId] = node; - }, - _indexForEventId(evId) { for (var i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { @@ -1130,11 +985,10 @@ module.exports = React.createClass({ }, _getLastDisplayedEventIndexIgnoringOwn: function() { - if (this.eventNodes === undefined) return null; + var messagePanel = this.refs.messagePanel; + if (messagePanel === undefined) return null; - var messageWrapper = this.refs.messagePanel; - if (messageWrapper === undefined) return null; - var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); + var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect(); for (var i = this.state.events.length-1; i >= 0; --i) { var ev = this.state.events[i]; @@ -1143,7 +997,7 @@ module.exports = React.createClass({ continue; } - var node = this.eventNodes[ev.getId()]; + var node = messagePanel.getNodeForEventId(ev.getId()); if (!node) continue; var boundingRect = node.getBoundingClientRect(); @@ -1412,10 +1266,10 @@ module.exports = React.createClass({ var CallView = sdk.getComponent("voip.CallView"); var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var SearchBar = sdk.getComponent("rooms.SearchBar"); - var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); var TintableSvg = sdk.getComponent("elements.TintableSvg"); var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); var Loader = sdk.getComponent("elements.Spinner"); + var MessagePanel = sdk.getComponent("structures.MessagePanel"); if (!this._timelineWindow) { if (this.props.roomId) { @@ -1687,14 +1541,24 @@ module.exports = React.createClass({ var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); messagePanel = ( - { + this.refs.messagePanel = r; + this.updateTint(); + }} + hidden={ hideMessagePanel } + events={ this.state.events } + highlightedEventId={ this.props.highlightedEventId } + readMarkerEventId={ this.state.readMarkerEventId } + readMarkerGhostEventId={ this.state.readMarkerGhostEventId } + ourUserId={ MatrixClientPeg.get().credentials.userId } + suppressFirstDateSeparator={ this.state.canBackPaginate } + stickyBottom={ stickyBottom } + isConferenceUser={this.props.ConferenceHandler ? + this.props.ConferenceHandler.isConferenceUser : + null } onScroll={ this.onMessageListScroll } onFillRequest={ this.onMessageListFillRequest } - style={ hideMessagePanel ? { display: 'none' } : {} } - stickyBottom={ stickyBottom }> -
  • - {this.getEventTiles()} -
    + /> ); } From f2377fa9fed6bfc20fb34ab9a838d8da77e1c333 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Wed, 10 Feb 2016 18:27:40 +0000 Subject: [PATCH 2/3] Factor out a TimelinePanel from RoomView --- src/component-index.js | 1 + src/components/structures/RoomView.js | 426 ++++--------------- src/components/structures/TimelinePanel.js | 449 +++++++++++++++++++++ 3 files changed, 518 insertions(+), 358 deletions(-) create mode 100644 src/components/structures/TimelinePanel.js diff --git a/src/component-index.js b/src/component-index.js index a0abd33348..eaf286520e 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -35,6 +35,7 @@ module.exports.components['structures.MessagePanel'] = require('./components/str module.exports.components['structures.RoomStatusBar'] = require('./components/structures/RoomStatusBar'); module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); +module.exports.components['structures.TimelinePanel'] = require('./components/structures/TimelinePanel'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar'); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index afe3a5112f..76a79bec03 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -25,7 +25,6 @@ var ReactDOM = require("react-dom"); var q = require("q"); var classNames = require("classnames"); var Matrix = require("matrix-js-sdk"); -var EventTimeline = Matrix.EventTimeline; var MatrixClientPeg = require("../../MatrixClientPeg"); var ContentMessages = require("../../ContentMessages"); @@ -41,14 +40,9 @@ var dis = require("../../dispatcher"); var Tinter = require("../../Tinter"); var rate_limited_func = require('../../ratelimitedfunc'); -var PAGINATE_SIZE = 20; -var INITIAL_SIZE = 20; -var SEND_READ_RECEIPT_DELAY = 2000; -var TIMELINE_CAP = 1000; // the most events to show in a timeline +var DEBUG = false; -var DEBUG_SCROLL = false; - -if (DEBUG_SCROLL) { +if (DEBUG) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { @@ -80,9 +74,7 @@ module.exports = React.createClass({ var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { room: room, - events: [], - canBackPaginate: true, - paginating: room != null, + roomLoading: !room, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, @@ -91,11 +83,8 @@ module.exports = React.createClass({ searchResults: null, hasUnsentMessages: this._hasUnsentMessages(room), callState: null, - timelineLoading: true, // track whether our room timeline is loading guestsCanJoin: false, canPeek: false, - readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null, - readMarkerGhostEventId: undefined, // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. It has the effect of hiding the @@ -105,13 +94,11 @@ module.exports = React.createClass({ }, componentWillMount: function() { - this.last_rr_sent_event_id = undefined; this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room", this.onRoom); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); - MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); // xchat-style tab complete, add a colon if tab @@ -126,13 +113,6 @@ module.exports = React.createClass({ }); - // to make the timeline load work correctly, build up a chain of promises which - // take us through the necessary steps. - - // First of all, we may need to load the room. Construct a promise - // which resolves to the Room object. - var roomProm; - // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can publicly join or were invited to. (we can /join) @@ -143,93 +123,28 @@ module.exports = React.createClass({ if (!this.state.room) { console.log("Attempting to peek into room %s", this.props.roomId); - roomProm = MatrixClientPeg.get().peekInRoom(this.props.roomId).then((room) => { + MatrixClientPeg.get().peekInRoom(this.props.roomId).then((room) => { this.setState({ - room: room + room: room, + roomLoading: false, }); - return room; - }); - } else { - roomProm = q(this.state.room); - } - - // Next, load the timeline. - roomProm.then((room) => { - this._calculatePeekRules(room); - return this._initTimeline(this.props); - }).catch((err) => { - // This won't necessarily be a MatrixError, but we duck-type - // here and say if it's got an 'errcode' key with the right value, - // it means we can't peek. - if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { - // This is fine: the room just isn't peekable (we assume). - this.setState({ - timelineLoading: false, - }); - } else { - throw err; - } - }).done(); - }, - - _initTimeline: function(props) { - var initialEvent = props.eventId; - var pixelOffset = props.eventPixelOffset; - return this._loadTimeline(initialEvent, pixelOffset); - }, - - /** - * (re)-load the event timeline, and initialise the scroll state, centered - * around the given event. - * - * @param {string?} eventId the event to focus on. If undefined, will - * scroll to the bottom of the room. - * - * @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. - * - * returns a promise which will resolve when the load completes. - */ - _loadTimeline: function(eventId, pixelOffset) { - // TODO: we could optimise this, by not resetting the window if the - // event is in the current window (though it's not obvious how we can - // tell if the current window is on the live event stream) - - this.setState({ - events: [], - searchResults: null, // we may have arrived here by clicking on a - // search result. Hide the results. - timelineLoading: true, - }); - - this._timelineWindow = new Matrix.TimelineWindow( - MatrixClientPeg.get(), this.state.room, - {windowLimit: TIMELINE_CAP}); - - return this._timelineWindow.load(eventId, INITIAL_SIZE).then(() => { - debuglog("RoomView: timeline loaded"); - this._onTimelineUpdated(true); - }).finally(() => { - this.setState({ - timelineLoading: false, - }, () => { - // initialise the scroll state of the message panel - if (!this.refs.messagePanel) { - // this shouldn't happen. - console.log("can't initialise scroll state because " + - "messagePanel didn't load"); - return; - } - if (eventId) { - this.refs.messagePanel.scrollToEvent(eventId, pixelOffset); + this._onRoomLoaded(room); + }, (err) => { + // This won't necessarily be a MatrixError, but we duck-type + // here and say if it's got an 'errcode' key with the right value, + // it means we can't peek. + if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { + // This is fine: the room just isn't peekable (we assume). + this.setState({ + roomLoading: false, + }); } else { - this.refs.messagePanel.scrollToBottom(); + throw err; } - - this.sendReadReceipt(); - }); - }); + }).done(); + } else { + this._onRoomLoaded(this.state.room); + } }, componentWillUnmount: function() { @@ -256,7 +171,6 @@ module.exports = React.createClass({ MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); - MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); } @@ -311,15 +225,6 @@ module.exports = React.createClass({ callState: callState }); - break; - 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(); break; } }, @@ -330,9 +235,8 @@ module.exports = React.createClass({ } if (newProps.eventId != this.props.eventId) { - console.log("RoomView switching to eventId " + newProps.eventId + - " (was " + this.props.eventId + ")"); - return this._initTimeline(newProps); + // when we change focussed event id, hide the search results. + this.setState({searchResults: null}); } }, @@ -361,15 +265,12 @@ module.exports = React.createClass({ }); } } + }, - // 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 - - // so there is no need update the state here. - // - if (this.refs.messagePanel) { - this.refs.messagePanel.checkFillState(); - } + // called when state.room is first initialised (either at initial load, + // after a successful peek, or after we join the room). + _onRoomLoaded: function(room) { + this._calculatePeekRules(room); }, _calculatePeekRules: function(room) { @@ -394,19 +295,25 @@ module.exports = React.createClass({ // set it in our state and start using it (ie. init the timeline) // This will happen if we start off viewing a room we're not joined, // then join it whilst RoomView is looking at that room. - if (room.roomId == this.props.roomId) { + if (room.roomId == this.props.roomId && !this.state.room) { this.setState({ room: room }); - this._initTimeline(this.props).done(); + this._onRoomLoaded(room); } }, onRoomName: function(room) { - if (room.roomId == this.props.roomId) { - this.setState({ - room: room - }); + // NB don't set state.room here. + // + // When peeking, this event lands *before* the timeline is correctly + // synced; if we set state.room here, the TimelinePanel will be + // instantiated, and it will initialise its scroll state, with *no + // events*. In short, the scroll state will be all messed up. + // + // There's no need to set state.room here anyway. + if (room.roomId == this.props.roomId) { + this.forceUpdate(); } }, @@ -433,38 +340,6 @@ module.exports = React.createClass({ } }, - onRoomReceipt: function(receiptEvent, room) { - if (room.roomId == this.props.roomId) { - var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); - var readMarkerGhostEventId = this.state.readMarkerGhostEventId; - if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) { - readMarkerGhostEventId = this.state.readMarkerEventId; - } - - - // if the event after the one referenced in the read receipt if sent by us, do nothing since - // this is a temporary period before the synthesized receipt for our own message arrives - var readMarkerGhostEventIndex; - for (var i = 0; i < this.state.events.length; ++i) { - if (this.state.events[i].getId() == readMarkerGhostEventId) { - readMarkerGhostEventIndex = i; - break; - } - } - if (readMarkerGhostEventIndex + 1 < this.state.events.length) { - var nextEvent = this.state.events[readMarkerGhostEventIndex + 1]; - if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) { - readMarkerGhostEventId = undefined; - } - } - - this.setState({ - readMarkerEventId: readMarkerEventId, - readMarkerGhostEventId: readMarkerGhostEventId, - }); - } - }, - onRoomMemberTyping: function(ev, member) { this.forceUpdate(); }, @@ -576,23 +451,6 @@ module.exports = React.createClass({ ); }, 500), - _onTimelineUpdated: function(gotResults) { - // we might have switched rooms since the load started - just bin - // the results if so. - if (this.unmounted) return; - - this.setState({ - paginating: false, - }); - - if (gotResults) { - this.setState({ - events: this._timelineWindow.getEvents(), - canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS), - }); - } - }, - onSearchResultsFillRequest: function(backwards) { if (!backwards) return q(false); @@ -608,23 +466,6 @@ module.exports = React.createClass({ } }, - // set off a pagination request. - onMessageListFillRequest: function(backwards) { - var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; - if(!this._timelineWindow.canPaginate(dir)) { - debuglog("RoomView: can't paginate at this time; backwards:"+backwards); - return q(false); - } - this.setState({paginating: true}); - - debuglog("RoomView: Initiating paginate; backwards:"+backwards); - return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => { - debuglog("RoomView: paginate complete backwards:"+backwards+"; success:"+r); - this._onTimelineUpdated(r); - return r; - }); - }, - onResendAllClick: function() { var eventsToResend = this._getUnsentMessages(this.state.room); eventsToResend.forEach(function(event) { @@ -701,19 +542,16 @@ module.exports = React.createClass({ }, onMessageListScroll: function(ev) { - if (this.refs.messagePanel.isAtBottom() && - !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - if (this.state.numUnreadMessages != 0) { - this.setState({ numUnreadMessages: 0 }); - } - if (!this.state.atEndOfLiveTimeline) { - this.setState({ atEndOfLiveTimeline: true }); - } + if (this.refs.messagePanel.isAtEndOfLiveTimeline()) { + this.setState({ + numUnreadMessages: 0, + atEndOfLiveTimeline: true, + }); } else { - if (this.state.atEndOfLiveTimeline) { - this.setState({ atEndOfLiveTimeline: false }); - } + this.setState({ + atEndOfLiveTimeline: false, + }); } }, @@ -928,87 +766,6 @@ module.exports = React.createClass({ return ret; }, - _indexForEventId(evId) { - for (var i = 0; i < this.state.events.length; ++i) { - if (evId == this.state.events[i].getId()) { - return i; - } - } - return null; - }, - - sendReadReceipt: function() { - if (!this.state.room) return; - if (!this.refs.messagePanel) return; - - // we don't want to see our RR marker dropping down as we scroll - // through old history. For now, do this just by leaving the RR where - // it is until we hit the bottom of the room, though ultimately we - // probably want to keep sending RR, but hide the RR until we reach - // the bottom of the room again, or something. - if (!this.state.atEndOfLiveTimeline) return; - - var currentReadUpToEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); - var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId); - - // We want to avoid sending out read receipts when we are looking at - // events in the past which are before the latest RR. - // - // For now, let's apply a heuristic: if (a) the event corresponding to - // the latest RR (either from the server, or sent by ourselves) doesn't - // appear in our timeline, and (b) we could forward-paginate the event - // timeline, then don't send any more RRs. - // - // This isn't watertight, as we could be looking at a section of - // timeline which is *after* the latest RR (so we should actually send - // RRs) - but that is a bit of a niche case. It will sort itself out when - // the user eventually hits the live timeline. - // - if (currentReadUpToEventId && currentReadUpToEventIndex === null && - this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - return; - } - - var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn(); - if (lastReadEventIndex === null) return; - - var lastReadEvent = this.state.events[lastReadEventIndex]; - - // we also remember the last read receipt we sent to avoid spamming the same one at the server repeatedly - if (lastReadEventIndex > currentReadUpToEventIndex && this.last_rr_sent_event_id != lastReadEvent.getId()) { - this.last_rr_sent_event_id = lastReadEvent.getId(); - MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => { - // it failed, so allow retries next time the user is active - this.last_rr_sent_event_id = undefined; - }); - } - }, - - _getLastDisplayedEventIndexIgnoringOwn: function() { - var messagePanel = this.refs.messagePanel; - if (messagePanel === undefined) return null; - - var wrapperRect = ReactDOM.findDOMNode(messagePanel).getBoundingClientRect(); - - 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) { - continue; - } - - var node = messagePanel.getNodeForEventId(ev.getId()); - if (!node) continue; - - var boundingRect = node.getBoundingClientRect(); - - if (boundingRect.bottom < wrapperRect.bottom) { - return i; - } - } - return null; - }, - onSettingsClick: function() { this.showSettings(true); }, @@ -1113,18 +870,7 @@ module.exports = React.createClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { - // if we can't forward-paginate the existing timeline, then there - // is no point reloading it - just jump straight to the bottom. - // - // Otherwise, reload the timeline rather than trying to paginate - // through all of space-time. - if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - this._loadTimeline(); - } else { - if (this.refs.messagePanel) { - this.refs.messagePanel.scrollToBottom(); - } - } + this.refs.messagePanel.jumpToLiveTimeline(); }, // get the current scroll position of the room, so that it can be @@ -1266,14 +1012,15 @@ module.exports = React.createClass({ var CallView = sdk.getComponent("voip.CallView"); var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var SearchBar = sdk.getComponent("rooms.SearchBar"); + var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); var TintableSvg = sdk.getComponent("elements.TintableSvg"); var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); var Loader = sdk.getComponent("elements.Spinner"); - var MessagePanel = sdk.getComponent("structures.MessagePanel"); + var TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - if (!this._timelineWindow) { + if (!this.state.room) { if (this.props.roomId) { - if (this.state.timelineLoading) { + if (this.state.roomLoading) { return (
    @@ -1355,7 +1102,6 @@ module.exports = React.createClass({ var scrollheader_classes = classNames({ mx_RoomView_scrollheader: true, - loading: this.state.paginating }); var statusBar; @@ -1510,57 +1256,21 @@ module.exports = React.createClass({ hideMessagePanel = true; } - var messagePanel; - - // just show a spinner while the timeline loads. - // - // put it in a div of the right class (mx_RoomView_messagePanel) so - // that the order in the roomview flexbox is correct, and - // mx_RoomView_messageListWrapper to position the inner div in the - // right place. - // - // Note that the click-on-search-result functionality relies on the - // fact that the messagePanel is hidden while the timeline reloads, - // but that the RoomHeader (complete with search term) continues to - // exist. - if (this.state.timelineLoading) { - messagePanel = ( -
    - -
    - ); - } else { - // give the messagepanel a stickybottom if we're at the end of the - // live timeline, so that the arrival of new events triggers a - // scroll. - // - // Make sure that stickyBottom is *false* if we can paginate - // forwards, otherwise if somebody hits the bottom of the loaded - // events when viewing historical messages, we get stuck in a loop - // of paginating our way through the entire history of the room. - var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); - - messagePanel = ( - { - this.refs.messagePanel = r; - this.updateTint(); - }} - hidden={ hideMessagePanel } - events={ this.state.events } - highlightedEventId={ this.props.highlightedEventId } - readMarkerEventId={ this.state.readMarkerEventId } - readMarkerGhostEventId={ this.state.readMarkerGhostEventId } - ourUserId={ MatrixClientPeg.get().credentials.userId } - suppressFirstDateSeparator={ this.state.canBackPaginate } - stickyBottom={ stickyBottom } - isConferenceUser={this.props.ConferenceHandler ? - this.props.ConferenceHandler.isConferenceUser : - null } - onScroll={ this.onMessageListScroll } - onFillRequest={ this.onMessageListFillRequest } - /> - ); - } + var messagePanel = ( + { + this.refs.messagePanel = r; + this.updateTint(); + }} + room={this.state.room} + hidden={hideMessagePanel} + highlightedEventId={this.props.highlightedEventId} + eventId={this.props.eventId} + eventPixelOffset={this.props.eventPixelOffset} + isConferenceUser={this.props.ConferenceHandler ? + this.props.ConferenceHandler.isConferenceUser : + null } + onScroll={ this.onMessageListScroll } + />); return (
    diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js new file mode 100644 index 0000000000..6ca5765af8 --- /dev/null +++ b/src/components/structures/TimelinePanel.js @@ -0,0 +1,449 @@ +/* +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. +*/ + +var React = require('react'); +var ReactDOM = require("react-dom"); +var q = require("q"); + +var Matrix = require("matrix-js-sdk"); +var EventTimeline = Matrix.EventTimeline; + +var sdk = require('../../index'); +var MatrixClientPeg = require("../../MatrixClientPeg"); +var dis = require("../../dispatcher"); + +var PAGINATE_SIZE = 20; +var INITIAL_SIZE = 20; +var TIMELINE_CAP = 1000; // the most events to show in a timeline + +var DEBUG = false; + +if (DEBUG) { + // using bind means that we get to keep useful line numbers in the console + var debuglog = console.log.bind(console); +} else { + var debuglog = function () {}; +} + +/* + * Component which shows the event timeline in a room view. + * + * Also responsible for handling and sending read receipts. + */ +module.exports = React.createClass({ + displayName: 'TimelinePanel', + + propTypes: { + // The js-sdk Room object for the room whose timeline we are + // representing. + room: React.PropTypes.object.isRequired, + + // true to give the component a 'display: hidden' style. + hidden: React.PropTypes.bool, + + // ID of an event to highlight. If undefined, no event will be highlighted. + // typically this will be either 'eventId' or undefined. + highlightedEventId: React.PropTypes.string, + + // id of an event to jump to. If not given, will go to the end of the + // live timeline. + 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. + eventPixelOffset: React.PropTypes.number, + + // callback to determine if a user is the magic freeswitch conference + // user. Takes one parameter, which is a user id. Should return true if + // the user is the conference user. + isConferenceUser: React.PropTypes.func, + + // callback which is called when the panel is scrolled. + onScroll: React.PropTypes.func, + }, + + getInitialState: function() { + return { + events: [], + timelineLoading: true, // track whether our room timeline is loading + canBackPaginate: true, + readMarkerEventId: this._getCurrentReadReceipt(), + readMarkerGhostEventId: null, + }; + }, + + componentWillMount: function() { + debuglog("TimelinePanel: mounting"); + + this.last_rr_sent_event_id = undefined; + this.dispatcherRef = dis.register(this.onAction); + MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); + MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); + + this._initTimeline(this.props); + }, + + componentWillReceiveProps: function(newProps) { + if (newProps.room !== this.props.room) { + throw new Error("changing room on a TimelinePanel is not supported"); + } + + if (newProps.eventId != this.props.eventId) { + console.log("TimelinePanel switching to eventId " + newProps.eventId + + " (was " + this.props.eventId + ")"); + return this._initTimeline(newProps); + } + }, + + componentWillUnmount: function() { + // set a boolean to say we've been unmounted, which any pending + // promises can use to throw away their results. + // + // (We could use isMounted, but facebook have deprecated that.) + this.unmounted = true; + + dis.unregister(this.dispatcherRef); + MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); + MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); + }, + + // set off a pagination request. + onMessageListFillRequest: function(backwards) { + var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; + if(!this._timelineWindow.canPaginate(dir)) { + debuglog("TimelinePanel: can't paginate at this time; backwards:"+backwards); + return q(false); + } + debuglog("TimelinePanel: Initiating paginate; backwards:"+backwards); + return this._timelineWindow.paginate(dir, PAGINATE_SIZE).then((r) => { + debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r); + this._onTimelineUpdated(r); + return r; + }); + }, + + 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(); + break; + } + }, + + onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { + // ignore events for other rooms + if (room !== this.props.room) return; + + // ignore anything but real-time updates at the end of the room: + // updates from pagination will happen when the paginate completes. + if (toStartOfTimeline || !data || !data.liveEvent) return; + + // 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 - + // so there is no need update the state here. + // + if (this.refs.messagePanel) { + this.refs.messagePanel.checkFillState(); + } + }, + + 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, + readMarkerGhostEventId: oldReadMarker, + }); + }, + + sendReadReceipt: function() { + if (!this.refs.messagePanel) return; + + var currentReadUpToEventId = this._getCurrentReadReceipt(); + var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId); + + // We want to avoid sending out read receipts when we are looking at + // events in the past which are before the latest RR. + // + // For now, let's apply a heuristic: if (a) the event corresponding to + // the latest RR (either from the server, or sent by ourselves) doesn't + // appear in our timeline, and (b) we could forward-paginate the event + // timeline, then don't send any more RRs. + // + // This isn't watertight, as we could be looking at a section of + // timeline which is *after* the latest RR (so we should actually send + // RRs) - but that is a bit of a niche case. It will sort itself out when + // the user eventually hits the live timeline. + // + if (currentReadUpToEventId && currentReadUpToEventIndex === null && + this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + return; + } + + var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn(); + if (lastReadEventIndex === null) return; + + var lastReadEvent = this.state.events[lastReadEventIndex]; + + // we also remember the last read receipt we sent to avoid spamming the + // same one at the server repeatedly + if (lastReadEventIndex > currentReadUpToEventIndex + && this.last_rr_sent_event_id != lastReadEvent.getId()) { + this.last_rr_sent_event_id = lastReadEvent.getId(); + MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => { + // it failed, so allow retries next time the user is active + this.last_rr_sent_event_id = undefined; + }); + } + }, + + /* jump down to the bottom of this room, where new events are arriving + */ + jumpToLiveTimeline: function() { + // if we can't forward-paginate the existing timeline, then there + // is no point reloading it - just jump straight to the bottom. + // + // Otherwise, reload the timeline rather than trying to paginate + // through all of space-time. + if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { + this._loadTimeline(); + } else { + if (this.refs.messagePanel) { + this.refs.messagePanel.scrollToBottom(); + } + } + }, + + /* return true if the content is fully scrolled down and we are + * at the end of the live timeline. + */ + isAtEndOfLiveTimeline: function() { + return this.refs.messagePanel + && this.refs.messagePanel.isAtBottom() + && this._timelineWindow + && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); + }, + + + /* get the current scroll state. See ScrollPanel.getScrollState for + * details. + * + * returns null if we are not mounted. + */ + getScrollState: function() { + if (!this.refs.messagePanel) { return null; } + return this.refs.messagePanel.getScrollState(); + }, + + render: function() { + var MessagePanel = sdk.getComponent("structures.MessagePanel"); + var Loader = sdk.getComponent("elements.Spinner"); + + // just show a spinner while the timeline loads. + // + // put it in a div of the right class (mx_RoomView_messagePanel) so + // that the order in the roomview flexbox is correct, and + // mx_RoomView_messageListWrapper to position the inner div in the + // right place. + // + // Note that the click-on-search-result functionality relies on the + // fact that the messagePanel is hidden while the timeline reloads, + // but that the RoomHeader (complete with search term) continues to + // exist. + if (this.state.timelineLoading) { + return ( +
    + +
    + ); + } + + // give the messagepanel a stickybottom if we're at the end of the + // live timeline, so that the arrival of new events triggers a + // scroll. + // + // Make sure that stickyBottom is *false* if we can paginate + // forwards, otherwise if somebody hits the bottom of the loaded + // events when viewing historical messages, we get stuck in a loop + // of paginating our way through the entire history of the room. + var stickyBottom = !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); + + return ( +