diff --git a/src/component-index.js b/src/component-index.js index 5a37a98913..63d2f3c39d 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -31,9 +31,11 @@ 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'); +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'); @@ -70,6 +72,7 @@ module.exports.components['views.messages.TextualEvent'] = require('./components module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings'); module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings'); +module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel'); module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList'); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js new file mode 100644 index 0000000000..cfa4735481 --- /dev/null +++ b/src/components/structures/MessagePanel.js @@ -0,0 +1,305 @@ +/* +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'); + +/* (almost) 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: none' 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, + + // 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 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, + }, + + componentWillMount: function() { + // the event after which we put a visible unread marker on the last + // render cycle; null if readMarkerVisible was false or the RM was + // suppressed (eg because it was at the end of the timeline) + this.currentReadMarkerEventId = null; + + // the event after which we are showing a disappearing read marker + // animation + this.currentGhostEventId = null; + }, + + /* 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(); + } + }, + + _getEventTiles: function() { + var EventTile = sdk.getComponent('rooms.EventTile'); + + this.eventNodes = {}; + + var i; + + // first figure out which is the last event in the list which we're + // actually going to show; this allows us to behave slightly + // differently for the last event in the list. + for (i = this.props.events.length-1; i >= 0; i--) { + var mxEv = this.props.events[i]; + if (!EventTile.haveTileForEvent(mxEv)) { + continue; + } + + break; + } + var lastShownEventIndex = i; + + var ret = []; + + var prevEvent = null; // the last event we showed + + // assume there is no read marker until proven otherwise + var readMarkerVisible = false; + + for (i = 0; i < this.props.events.length; i++) { + var mxEv = this.props.events[i]; + var wantTile = true; + var eventId = mxEv.getId(); + + if (!EventTile.haveTileForEvent(mxEv)) { + wantTile = false; + } + + var last = (i == lastShownEventIndex); + + if (wantTile) { + ret.push(this._getTilesForEvent(prevEvent, mxEv, last)); + prevEvent = mxEv; + } else if (!mxEv.status) { + // if we aren't showing the event, put in a dummy scroll token anyway, so + // that we can scroll to the right place. + 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; + } + } 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. + ret.push(this._getReadMarkerGhostTile()); + this.currentGhostEventId = eventId; + } else if (eventId == this.currentGhostEventId) { + // if we're showing an animation, continue to show it. + ret.push(this._getReadMarkerGhostTile()); + } + } + + this.currentReadMarkerEventId = readMarkerVisible ? this.props.readMarkerEventId : null; + return ret; + }, + + _getTilesForEvent: function(prevEvent, mxEv, last) { + var EventTile = sdk.getComponent('rooms.EventTile'); + var DateSeparator = sdk.getComponent('messages.DateSeparator'); + var ret = []; + + // is this a continuation of the previous message? + var continuation = false; + if (prevEvent !== null && prevEvent.sender && mxEv.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.props.suppressFirstDateSeparator) || + (prevEvent != null && + new Date(prevEvent.getTs()).toDateString() + !== new Date(ts1).toDateString())) { + var dateSeparator =
  • ; + ret.push(dateSeparator); + continuation = false; + } + + 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( +
  • + +
  • + ); + + return ret; + }, + + _getReadMarkerTile: function() { + var hr; + hr =
    ; + + return ( +
  • + {hr} +
  • + ); + }, + + _getReadMarkerGhostTile: function() { + // reset the ghostEventId when the animation finishes, so that + // we can make a new one (and so that we don't run the + // animation code every time we render) + var completeFunc = () => { + this.currentGhostEventId = null; + }; + + var hr =
    ; + + // give it a key which depends on the event id. That will ensure that + // we get a new DOM node (restarting the animation) when the ghost + // moves to a different event. + return ( +
  • + {hr} +
  • + ); + }, + + _collectEventNode: function(eventId, node) { + this.eventNodes[eventId] = node; + }, + + + // once images in the events load, make the scrollPanel check the + // scroll offsets. + _onImageLoad: function() { + var scrollPanel = this.refs.messagePanel; + if (scrollPanel) { + scrollPanel.checkScroll(); + } + }, + + render: function() { + var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); + return ( + + {this._getEventTiles()} + + ); + }, +}); diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 2e0897e3d0..9f338d5fcd 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -61,11 +61,13 @@ module.exports = React.createClass({ getInitialState: function() { return { syncState: MatrixClientPeg.get().getSyncState(), + whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), }; }, componentWillMount: function() { MatrixClientPeg.get().on("sync", this.onSyncStateChange); + MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); }, componentDidUpdate: function(prevProps, prevState) { @@ -76,8 +78,10 @@ module.exports = React.createClass({ componentWillUnmount: function() { // we may have entirely lost our client as we're logging out before clicking login on the guest bar... - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange); + var client = MatrixClientPeg.get(); + if (client) { + client.removeListener("sync", this.onSyncStateChange); + client.removeListener("RoomMember.typing", this.onRoomMemberTyping); } }, @@ -90,6 +94,12 @@ module.exports = React.createClass({ }); }, + onRoomMemberTyping: function(ev, member) { + this.setState({ + whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + }); + }, + // determine if we need to call onResize _checkForResize: function(prevProps, prevState) { // figure out the old height and the new height of the status bar. We @@ -235,7 +245,7 @@ module.exports = React.createClass({ ); } - var typingString = WhoIsTyping.whoIsTypingString(this.props.room); + var typingString = this.state.whoisTypingString; if (typingString) { return (
    diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index b5e1b20ac7..cb9939b56b 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -15,18 +15,15 @@ limitations under the License. */ // TODO: This component is enormous! There's several things which could stand-alone: -// - Aux component // - 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"); 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"); @@ -42,14 +39,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 { @@ -81,17 +73,11 @@ 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 { room: room, - events: [], - canBackPaginate: true, - paginating: room != null, + roomLoading: !room, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, @@ -100,29 +86,24 @@ 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 // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, + + auxPanelMaxHeight: undefined, } }, 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.redaction", this.onRoomRedaction); 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 // completing at the start of the text @@ -136,13 +117,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) @@ -153,93 +127,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.scrollToToken(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() { @@ -264,11 +173,8 @@ module.exports = React.createClass({ if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); - MatrixClientPeg.get().removeListener("Room.redaction", this.onRoomRedaction); 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); } @@ -322,15 +228,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; } }, @@ -341,9 +238,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}); } }, @@ -372,26 +268,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(); - } }, - onRoomRedaction: function(ev, room) { - if (this.unmounted) return; - - // ignore events for other rooms - if (room.roomId != this.props.roomId) return; - - // we could skip an update if the event isn't in our timeline, - // but that's probably an early optimisation. - this.forceUpdate(); + // 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) { @@ -416,19 +298,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(); } }, @@ -455,42 +343,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(); - }, - onRoomStateMember: function(ev, state, member) { if (member.roomId === this.props.roomId) { // a member state changed in this room, refresh the tab complete list @@ -554,10 +406,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({ @@ -594,18 +442,7 @@ 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.roomView) { var roomView = ReactDOM.findDOMNode(this.refs.roomView); if (!roomView.ondrop) { @@ -615,27 +452,6 @@ module.exports = React.createClass({ roomView.addEventListener('dragend', this.onDragLeaveOrEnd); } } - - 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. - 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) { @@ -653,23 +469,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) { @@ -746,19 +545,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, + }); } }, @@ -976,226 +772,6 @@ module.exports = React.createClass({ return ret; }, - getEventTiles: function() { - var DateSeparator = sdk.getComponent('messages.DateSeparator'); - var EventTile = sdk.getComponent('rooms.EventTile'); - - // once images in the events load, make the scrollPanel check the - // scroll offsets. - var onImageLoad = () => { - var scrollPanel = this.refs.messagePanel; - if (scrollPanel) { - scrollPanel.checkScroll(); - } - } - - var ret = []; - var count = 0; - - 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]; - var eventId = mxEv.getId(); - - // 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; - - var wantTile = true; - - if (!EventTile.haveTileForEvent(mxEv)) { - wantTile = false; - } - else if (this.props.ConferenceHandler && mxEv.getType() === "m.room.member") { - if (this.props.ConferenceHandler.isConferenceUser(mxEv.getSender()) || - this.props.ConferenceHandler.isConferenceUser(mxEv.getStateKey())) { - // don't suppress conf user join/parts entirely, as they're useful! - // wantTile = false; - } - } - - if (!wantTile) { - // if we aren't showing the event, put in a dummy scroll token anyway, so - // that we can scroll to the right place. - ret.push(
  • ); - continue; - } - - // 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 highlight = (eventId == this.props.highlightedEventId); - - 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()) { - 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, true); - 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() { - if (this.eventNodes === undefined) return null; - - var messageWrapper = this.refs.messagePanel; - if (messageWrapper === undefined) return null; - var wrapperRect = ReactDOM.findDOMNode(messageWrapper).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 = this.eventNodes[ev.getId()]; - if (!node) continue; - - var boundingRect = node.getBoundingClientRect(); - - if (boundingRect.bottom < wrapperRect.bottom) { - return i; - } - } - return null; - }, - onSettingsClick: function() { this.showSettings(true); }, @@ -1298,28 +874,9 @@ module.exports = React.createClass({ }); }, - onConferenceNotificationClick: function() { - dis.dispatch({ - action: 'place_call', - type: "video", - room_id: this.props.roomId - }); - }, - // 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 @@ -1390,25 +947,10 @@ module.exports = React.createClass({ // but it's better than the video going missing entirely if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; - if (this.refs.callView) { - var fullscreenElement = - (document.fullscreenElement || - document.mozFullScreenElement || - document.webkitFullscreenElement); - if (!fullscreenElement) { - var video = this.refs.callView.getVideoView().getRemoteVideoElement(); - video.style.maxHeight = auxPanelMaxHeight + "px"; - } - } - - // we need to do this for general auxPanels too - if (this.refs.auxPanel) { - this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px"; - } - - // the above might have made the aux panel resize itself, so now - // we need to tell the gemini panel to adapt. - this.onChildResize(); + // we may need to resize the gemini panel after changing the aux panel + // size, so add a callback to onChildResize. + this.setState({auxPanelMaxHeight: auxPanelMaxHeight}, + this.onChildResize); }, onFullscreenClick: function() { @@ -1442,11 +984,6 @@ module.exports = React.createClass({ }); }, - onCallViewResize: function() { - this.onChildResize(); - this.onResize(); - }, - onChildResize: function() { // When the video, status bar, or the message composer resizes, the // scroll panel also changes size. Work around GeminiScrollBar fail by @@ -1469,17 +1006,18 @@ module.exports = React.createClass({ render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); - var CallView = sdk.getComponent("voip.CallView"); var RoomSettings = sdk.getComponent("rooms.RoomSettings"); + var AuxPanel = sdk.getComponent("rooms.AuxPanel"); 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 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 (
    @@ -1553,7 +1091,6 @@ module.exports = React.createClass({ var scrollheader_classes = classNames({ mx_RoomView_scrollheader: true, - loading: this.state.paginating }); var statusBar; @@ -1613,28 +1150,16 @@ module.exports = React.createClass({ ); } - var conferenceCallNotification = null; - if (this.state.displayConfCallNotification) { - var supportedText; - if (!MatrixClientPeg.get().supportsVoip()) { - supportedText = " (unsupported)"; - } - conferenceCallNotification = ( -
    - Ongoing conference call {supportedText} -
    - ); - } - - var fileDropTarget = null; - if (this.state.draggingFile) { - fileDropTarget =
    -
    -
    - Drop file here to upload -
    -
    ; - } + var auxPanel = ( + + { aux } + + ); var messageComposer, searchInfo; var canSpeak = ( @@ -1709,47 +1234,20 @@ 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.getEventTiles()} -
    - ); - } + var messagePanel = ( + { + this.refs.messagePanel = r; + if(r) { + this.updateTint(); + } + }} + room={this.state.room} + hidden={hideMessagePanel} + highlightedEventId={this.props.highlightedEventId} + eventId={this.props.eventId} + eventPixelOffset={this.props.eventPixelOffset} + onScroll={ this.onMessageListScroll } + />); return (
    @@ -1765,13 +1263,7 @@ module.exports = React.createClass({ onLeaveClick={ (myMember && myMember.membership === "join") ? this.onLeaveClick : null } /> -
    - { fileDropTarget } - - { conferenceCallNotification } - { aux } -
    + { auxPanel } { messagePanel } { searchResultsPanel }
    diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js new file mode 100644 index 0000000000..adad8bc060 --- /dev/null +++ b/src/components/structures/TimelinePanel.js @@ -0,0 +1,463 @@ +/* +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: none' 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 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(), + }; + }, + + 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); + MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); + + 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); + + var client = MatrixClientPeg.get(); + if (client) { + client.removeListener("Room.timeline", this.onRoomTimeline); + client.removeListener("Room.receipt", this.onRoomReceipt); + client.removeListener("Room.redaction", this.onRoomRedaction); + } + }, + + // 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, + }); + }, + + onRoomRedaction: function(ev, room) { + if (this.unmounted) return; + + // ignore events for other rooms + if (room !== this.props.room) return; + + // we could skip an update if the event isn't in our timeline, + // but that's probably an early optimisation. + this.forceUpdate(); + }, + + sendReadReceipt: function() { + if (!this.refs.messagePanel) return; + + var currentReadUpToEventId = this._getCurrentReadReceipt(true); + 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(); + }, + + _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: [], + timelineLoading: true, + }); + + this._timelineWindow = new Matrix.TimelineWindow( + MatrixClientPeg.get(), this.props.room, + {windowLimit: TIMELINE_CAP}); + + return this._timelineWindow.load(eventId, INITIAL_SIZE).then(() => { + debuglog("TimelinePanel: 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); + } else { + this.refs.messagePanel.scrollToBottom(); + } + + this.sendReadReceipt(); + }); + }); + }, + + _onTimelineUpdated: function(gotResults) { + // we might have switched rooms since the load started - just bin + // the results if so. + if (this.unmounted) return; + + if (gotResults) { + this.setState({ + events: this._timelineWindow.getEvents(), + canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS), + }); + } + }, + + _indexForEventId: function(evId) { + for (var i = 0; i < this.state.events.length; ++i) { + if (evId == this.state.events[i].getId()) { + return i; + } + } + return null; + }, + + _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; + }, + + /** + * get the id of the event corresponding to our user's latest read-receipt. + * + * @param {Boolean} ignoreSynthesized If true, return only receipts that + * have been sent by the server, not + * implicit ones generated by the JS + * SDK. + */ + _getCurrentReadReceipt: function(ignoreSynthesized) { + var client = MatrixClientPeg.get(); + // the client can be null on logout + if (client == null) + return null; + + var myUserId = client.credentials.userId; + return this.props.room.getEventReadUpTo(myUserId, ignoreSynthesized); + }, + + + 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 ( +