diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index dbb3dbf83e..bd8aa31a6f 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -39,7 +39,8 @@ function createClient(hs_url, is_url, user_id, access_token, guestAccess) { baseUrl: hs_url, idBaseUrl: is_url, accessToken: access_token, - userId: user_id + userId: user_id, + timelineSupport: true, }; if (localStorage) { diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 5c2fd21a39..fb6d36ce35 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -316,7 +316,10 @@ module.exports = React.createClass({ }); break; case 'view_room': - this._viewRoom(payload.room_id, payload.show_settings); + // by default we autoPeek rooms, unless we were called explicitly with + // autoPeek=false by something like RoomDirectory who has already peeked + this.setState({ autoPeek : payload.auto_peek === false ? false : true }); + this._viewRoom(payload.room_id, payload.show_settings, payload.event_id); break; case 'view_prev_room': roomIndexDelta = -1; @@ -351,7 +354,8 @@ module.exports = React.createClass({ if (foundRoom) { dis.dispatch({ action: 'view_room', - room_id: foundRoom.roomId + room_id: foundRoom.roomId, + event_id: payload.event_id, }); return; } @@ -360,7 +364,8 @@ module.exports = React.createClass({ function(result) { dis.dispatch({ action: 'view_room', - room_id: result.room_id + room_id: result.room_id, + event_id: payload.event_id, }); }); break; @@ -432,15 +437,34 @@ module.exports = React.createClass({ }); }, - _viewRoom: function(roomId, showSettings) { + // switch view to the given room + // + // eventId is optional and will cause a switch to the context of that + // particular event. + _viewRoom: function(roomId, showSettings, eventId) { // before we switch room, record the scroll state of the current room this._updateScrollMap(); this.focusComposer = true; + var newState = { currentRoom: roomId, + initialEventId: eventId, + highlightedEventId: eventId, + initialEventPixelOffset: undefined, page_type: this.PageTypes.RoomView, }; + + // if we aren't given an explicit event id, look for one in the + // scrollStateMap. + if (!eventId) { + var scrollState = this.scrollStateMap[roomId]; + if (scrollState) { + newState.initialEventId = scrollState.focussedEvent; + newState.initialEventPixelOffset = scrollState.pixelOffset; + } + } + if (this.sdkReady) { // if the SDK is not ready yet, remember what room // we're supposed to be on but don't notify about @@ -462,14 +486,14 @@ module.exports = React.createClass({ Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); } + if (eventId) { + presentedId += "/"+eventId; + } this.notifyNewScreen('room/'+presentedId); newState.ready = true; } this.setState(newState); - if (this.scrollStateMap[roomId]) { - var scrollState = this.scrollStateMap[roomId]; - this.refs.roomView.restoreScrollState(scrollState); - } + if (this.refs.roomView && showSettings) { this.refs.roomView.showSettings(true); } @@ -517,9 +541,11 @@ module.exports = React.createClass({ if (self.starting_room_alias) { dis.dispatch({ action: 'view_room_alias', - room_alias: self.starting_room_alias + room_alias: self.starting_room_alias, + event_id: self.starting_event_id, }); delete self.starting_room_alias; + delete self.starting_event_id; } else if (!self.state.page_type) { if (!self.state.currentRoom) { var firstRoom = null; @@ -645,23 +671,28 @@ module.exports = React.createClass({ return; } - var roomString = screen.split('/')[1]; + var segments = screen.substring(5).split('/'); + var roomString = segments[0]; + var eventId = segments[1]; // undefined if no event id given if (roomString[0] == '#') { if (this.state.logged_in) { dis.dispatch({ action: 'view_room_alias', - room_alias: roomString + room_alias: roomString, + event_id: eventId, }); } else { // Okay, we'll take you here soon... this.starting_room_alias = roomString; + this.starting_event_id = eventId; // ...but you're still going to have to log in. this.notifyNewScreen('login'); } } else { dis.dispatch({ action: 'view_room', - room_id: roomString + room_id: roomString, + event_id: eventId, }); } } @@ -839,6 +870,10 @@ module.exports = React.createClass({ ); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index b461ef25a5..6dbff018ef 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -26,6 +26,7 @@ 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"); @@ -44,6 +45,7 @@ var Tinter = require("../../Tinter"); 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_SCROLL = false; @@ -60,6 +62,22 @@ module.exports = React.createClass({ ConferenceHandler: React.PropTypes.any, roomId: React.PropTypes.string, autoPeek: React.PropTypes.bool, // Now unused, left here temporarily to avoid merge conflicts with @richvdh's branch. + + roomId: React.PropTypes.string.isRequired, + + // id of an event to jump to. If not given, will use the read-up-to-marker. + 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, + + // ID of an event to highlight. If undefined, no event will be highlighted. + // Typically this will either be the same as 'eventId', or undefined. + highlightedEventId: React.PropTypes.string, + + autoPeek: React.PropTypes.bool, // should we try to peek the room on mount, or has whoever invoked us already initiated a peek? }, getDefaultProps: function() { @@ -76,7 +94,9 @@ module.exports = React.createClass({ var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { room: room, - messageCap: INITIAL_SIZE, + events: [], + canBackPaginate: true, + paginating: room != null, editingRoomSettings: false, uploadingRoomSettings: false, numUnreadMessages: 0, @@ -86,19 +106,22 @@ module.exports = React.createClass({ syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, - autoPeekDone: false, // track whether our autoPeek (if any) has completed) + timelineLoading: true, // track whether our room timeline is loading guestsCanJoin: false, canPeek: false, readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null, readMarkerGhostEventId: undefined, - atBottom: true, + + // 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, } }, componentWillMount: function() { this.last_rr_sent_event_id = undefined; this.dispatcherRef = dis.register(this.onAction); - MatrixClientPeg.get().on("Room", this.onNewRoom); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); @@ -116,6 +139,15 @@ module.exports = React.createClass({ this.forceUpdate(); } }); + + + // 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) @@ -124,22 +156,92 @@ module.exports = React.createClass({ // We can /peek though. If it fails then we present the join UI. If it // succeeds then great, show the preview (but we still may be able to /join!). if (!this.state.room) { - if (this.props.autoPeek) { - console.log("Attempting to peek into room %s", this.props.roomId); - MatrixClientPeg.get().peekInRoom(this.props.roomId).catch((err) => { - console.error("Failed to peek into room: %s", err); - }).finally(() => { - // we don't need to do anything - JS SDK will emit Room events - // which will update the UI. - this.setState({ - autoPeekDone: true - }); - }); + if (!this.props.autoPeek) { + console.log("No room loaded, and autopeek disabled"); + return; } + + console.log("Attempting to peek into room %s", this.props.roomId); + + roomProm = MatrixClientPeg.get().peekInRoom(this.props.roomId).catch((err) => { + console.error("Failed to peek into room: %s", err); + throw err; + }).then((room) => { + this.setState({ + room: room + }); + return room; + }); + } else { + roomProm = q(this.state.room); } - else { - this._calculatePeekRules(this.state.room); + + // Next, load the timeline. + roomProm.then((room) => { + this._calculatePeekRules(room); + return this._initTimeline(this.props); + }).done(); + }, + + _initTimeline: function(props) { + var initialEvent = props.eventId; + if (!initialEvent) { + // go to the 'read-up-to' mark if no explicit event given + initialEvent = this.state.readMarkerEventId; } + + 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.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); + } else { + this.refs.messagePanel.scrollToBottom(); + } + }); + }); }, componentWillUnmount: function() { @@ -162,7 +264,6 @@ module.exports = React.createClass({ } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener("Room", this.onNewRoom); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); @@ -208,10 +309,6 @@ module.exports = React.createClass({ var callState; if (call) { - // Call state has changed so we may be loading video elements - // which will obscure the message log. - // scroll to bottom - this.scrollToBottom(); callState = call.call_state; } else { @@ -248,52 +345,52 @@ module.exports = React.createClass({ }); }, - // MatrixRoom still showing the messages from the old room? - // Set the key to the room_id. Sadly you can no longer get at - // the key from inside the component, or we'd check this in code. - /*componentWillReceiveProps: function(props) { - },*/ + componentWillReceiveProps: function(newProps) { + if (newProps.roomId != this.props.roomId) { + throw new Error("changing room on a RoomView is not supported"); + } - onRoomTimeline: function(ev, room, toStartOfTimeline) { + if (newProps.eventId != this.props.eventId) { + console.log("RoomView switching to eventId " + newProps.eventId + + " (was " + this.props.eventId + ")"); + return this._initTimeline(newProps); + } + }, + + onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { if (this.unmounted) return; - // ignore anything that comes in whilst paginating: we get one - // event for each new matrix event so this would cause a huge - // number of UI updates. Just update the UI when the paginate - // call returns. - if (this.state.paginating) return; + // ignore events for other rooms + if (room.roomId != this.props.roomId) 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; // no point handling anything while we're waiting for the join to finish: // we'll only be showing a spinner. if (this.state.joining) return; - if (room.roomId != this.props.roomId) return; - var currentUnread = this.state.numUnreadMessages; - if (!toStartOfTimeline && - (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { + if (ev.getSender() !== MatrixClientPeg.get().credentials.userId) { // update unread count when scrolled up - if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { - currentUnread = 0; + if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { + // no change } else { - currentUnread += 1; + this.setState((state, props) => { + return {numUnreadMessages: state.numUnreadMessages + 1}; + }); } } - this.setState({ - room: MatrixClientPeg.get().getRoom(this.props.roomId), - numUnreadMessages: currentUnread - }); - }, - - onNewRoom: function(room) { - if (room.roomId == this.props.roomId) { - this.setState({ - room: room - }); + // 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(); } - - this._calculatePeekRules(room); }, _calculatePeekRules: function(room) { @@ -355,14 +452,14 @@ module.exports = React.createClass({ // 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 < room.timeline.length; ++i) { - if (room.timeline[i].getId() == readMarkerGhostEventId) { + for (var i = 0; i < this.state.events.length; ++i) { + if (this.state.events[i].getId() == readMarkerGhostEventId) { readMarkerGhostEventIndex = i; break; } } - if (readMarkerGhostEventIndex + 1 < room.timeline.length) { - var nextEvent = room.timeline[readMarkerGhostEventIndex + 1]; + 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; } @@ -372,6 +469,20 @@ module.exports = React.createClass({ readMarkerEventId: readMarkerEventId, readMarkerGhostEventId: readMarkerGhostEventId, }); + + + // if the scrollpanel is following the timeline, attempt to scroll + // it to bring the read message up to the middle of the panel. This + // will have no immediate effect (since we are already at the + // bottom), but will ensure that if there is no further user + // activity, but room activity continues, the read message will + // scroll up to the middle of the window, but no further. + // + // we do this here as well as in sendReadReceipt to deal with + // people using two clients at once. + if (this.refs.messagePanel && this.state.atEndOfLiveTimeline) { + this.refs.messagePanel.scrollToToken(readMarkerEventId); + } } }, @@ -494,7 +605,6 @@ module.exports = React.createClass({ var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); this.refs.messagePanel.initialised = true; - this.scrollToBottom(); this.sendReadReceipt(); this.updateTint(); @@ -511,17 +621,21 @@ module.exports = React.createClass({ } }, - _paginateCompleted: function() { - debuglog("paginate complete"); - - // we might have switched rooms since the paginate started - just bin + _onTimelineUpdated: function(gotResults) { + // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; this.setState({ - room: MatrixClientPeg.get().getRoom(this.props.roomId), paginating: false, }); + + if (gotResults) { + this.setState({ + events: this._timelineWindow.getEvents(), + canBackPaginate: this._timelineWindow.canPaginate(EventTimeline.BACKWARDS), + }); + } }, onSearchResultsFillRequest: function(backwards) { @@ -541,30 +655,19 @@ module.exports = React.createClass({ // set off a pagination request. onMessageListFillRequest: function(backwards) { - if (!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); - - // Either wind back the message cap (if there are enough events in the - // timeline to do so), or fire off a pagination request. - - if (this.state.messageCap < this.state.room.timeline.length) { - var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length); - debuglog("winding back message cap to", cap); - this.setState({messageCap: cap}); - return q(true); - } else if(this.state.room.oldState.paginationToken) { - var cap = this.state.messageCap + PAGINATE_SIZE; - debuglog("starting paginate to cap", cap); - this.setState({messageCap: cap, paginating: true}); - return MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE). - finally(this._paginateCompleted).then(true); } - }, + this.setState({paginating: true}); - // return true if there's more messages in the backlog which we aren't displaying - _canPaginate: function() { - return (this.state.messageCap < this.state.room.timeline.length) || - this.state.room.oldState.paginationToken; + 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() { @@ -606,18 +709,19 @@ module.exports = React.createClass({ }, onMessageListScroll: function(ev) { - if (this.refs.messagePanel.isAtBottom()) { + if (this.refs.messagePanel.isAtBottom() && + !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { if (this.state.numUnreadMessages != 0) { this.setState({ numUnreadMessages: 0 }); } - if (!this.state.atBottom) { - this.setState({ atBottom: true }); + if (!this.state.atEndOfLiveTimeline) { + this.setState({ atEndOfLiveTimeline: true }); } } else { - if (this.state.atBottom) { - this.setState({ atBottom: false }); - } + if (this.state.atEndOfLiveTimeline) { + this.setState({ atEndOfLiveTimeline: false }); + } } }, @@ -836,11 +940,10 @@ module.exports = React.createClass({ var EventTile = sdk.getComponent('rooms.EventTile'); var prevEvent = null; // the last event we showed - var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap); - var readMarkerIndex; var ghostIndex; - for (var i = startIdx; i < this.state.room.timeline.length; i++) { - var mxEv = this.state.room.timeline[i]; + var readMarkerIndex; + for (var i = 0; i < this.state.events.length; i++) { + var mxEv = this.state.events[i]; if (!EventTile.haveTileForEvent(mxEv)) { continue; @@ -886,7 +989,7 @@ module.exports = React.createClass({ // do we need a date separator since the last event? var ts1 = mxEv.getTs(); - if ((prevEvent == null && !this._canPaginate()) || + if ((prevEvent == null && !this.state.canBackPaginate) || (prevEvent != null && new Date(prevEvent.getTs()).toDateString() !== new Date(ts1).toDateString())) { var dateSeparator =
  • ; @@ -895,15 +998,17 @@ module.exports = React.createClass({ } var last = false; - if (i == this.state.room.timeline.length - 1) { + 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); ret.push(
  • - +
  • ); @@ -922,7 +1027,7 @@ module.exports = React.createClass({ var hr; hr = (
    ); ret.splice(ghostIndex, 0, ( @@ -1178,8 +1283,8 @@ module.exports = React.createClass({ }, _indexForEventId(evId) { - for (var i = 0; i < this.state.room.timeline.length; ++i) { - if (evId == this.state.room.timeline[i].getId()) { + for (var i = 0; i < this.state.events.length; ++i) { + if (evId == this.state.events[i].getId()) { return i; } } @@ -1188,13 +1293,33 @@ module.exports = React.createClass({ sendReadReceipt: function() { if (!this.state.room) return; + if (!this.refs.messagePanel) 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.room.timeline[lastReadEventIndex]; + 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()) { @@ -1203,6 +1328,19 @@ module.exports = React.createClass({ // it failed, so allow retries next time the user is active this.last_rr_sent_event_id = undefined; }); + + // if the scrollpanel is following the timeline, attempt to scroll + // it to bring the read message up to the middle of the panel. This + // will have no immediate effect (since we are already at the + // bottom), but will ensure that if there is no further user + // activity, but room activity continues, the read message will + // scroll up to the middle of the window, but no further. + // + // we do this here as well as in onRoomReceipt to cater for guest users + // (which do not send out read receipts). + if (this.state.atEndOfLiveTimeline) { + this.refs.messagePanel.scrollToToken(lastReadEvent.getId()); + } } }, @@ -1213,8 +1351,8 @@ module.exports = React.createClass({ if (messageWrapper === undefined) return null; var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); - for (var i = this.state.room.timeline.length-1; i >= 0; --i) { - var ev = this.state.room.timeline[i]; + 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; @@ -1327,78 +1465,58 @@ module.exports = React.createClass({ return this.state.numUnreadMessages + " new message" + (this.state.numUnreadMessages > 1 ? "s" : ""); }, - scrollToBottom: function() { - var messagePanel = this.refs.messagePanel; - if (!messagePanel) return; - messagePanel.scrollToBottom(); - }, - - // scroll the event view to put the given event at the bottom. - // - // pixel_offset gives the number of pixels between the bottom of the event - // and the bottom of the container. - scrollToEvent: function(eventId, pixelOffset) { - var messagePanel = this.refs.messagePanel; - if (!messagePanel) return; - - var idx = this._indexForEventId(eventId); - if (idx === null) { - // we don't seem to have this event in our timeline. Presumably - // it's fallen out of scrollback. We ought to backfill until we - // find it, but we'd have to be careful we didn't backfill forever - // looking for a non-existent event. - // - // for now, just scroll to the top of the buffer. - console.log("Refusing to scroll to unknown event "+eventId); - messagePanel.scrollToTop(); - return; - } - - // we might need to roll back the messagecap (to generate tiles for - // older messages). This just means telling getEventTiles to create - // tiles for events we already have in our timeline (we already know - // the event in question is in our timeline, so we shouldn't need to - // backfill). + // 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. // - // we actually wind back slightly further than the event in question, - // because we want the event to be at the *bottom* of the container. - // Don't roll it back past the timeline we have, though. - var minCap = this.state.room.timeline.length - Math.min(idx - INITIAL_SIZE, 0); - if (minCap > this.state.messageCap) { - this.setState({messageCap: minCap}); + // 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(); + } } - - // the scrollTokens on our DOM nodes are the event IDs, so we can pass - // eventId directly into _scrollToToken. - messagePanel.scrollToToken(eventId, pixelOffset); }, // get the current scroll position of the room, so that it can be - // restored when we switch back to it + // restored when we switch back to it. + // + // This returns an object with the following properties: + // + // focussedEvent: the ID of the 'focussed' event. Typically this is the + // last event fully visible in the viewport, though if we have done + // an explicit scroll to an explicit event, it will be that event. + // + // pixelOffset: the number of pixels the window is scrolled down from + // the focussedEvent. + // + // If there are no visible events, returns null. + // getScrollState: function() { var messagePanel = this.refs.messagePanel; if (!messagePanel) return null; - return messagePanel.getScrollState(); - }, + var scrollState = messagePanel.getScrollState(); - restoreScrollState: function(scrollState) { - var messagePanel = this.refs.messagePanel; - if (!messagePanel) return null; - - if(scrollState.atBottom) { - // we were at the bottom before. Ideally we'd scroll to the - // 'read-up-to' mark here. - messagePanel.scrollToBottom(); - - } else if (scrollState.lastDisplayedScrollToken) { - // we might need to backfill, so we call scrollToEvent rather than - // scrollToToken here. The scrollTokens on our DOM nodes are the - // event IDs, so lastDisplayedScrollToken will be the event ID we need, - // and we can pass it directly into scrollToEvent. - this.scrollToEvent(scrollState.lastDisplayedScrollToken, - scrollState.pixelOffset); + if (scrollState.stuckAtBottom) { + // we don't really expect to be in this state, but it will + // occasionally happen when no scroll state has been set on the + // messagePanel (ie, we didn't have an initial event (so it's + // probably a new room), there has been no user-initiated scroll, and + // no read-receipts have arrived to update the scroll position). + // + // Return null, which will cause us to scroll to last unread on + // reload. + return null; } + + return { + focussedEvent: scrollState.trackedScrollToken, + pixelOffset: scrollState.pixelOffset, + }; }, onResize: function(e) { @@ -1492,11 +1610,11 @@ module.exports = React.createClass({ var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); var TintableSvg = sdk.getComponent("elements.TintableSvg"); var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); + var Loader = sdk.getComponent("elements.Spinner"); - if (!this.state.room) { + if (!this._timelineWindow) { if (this.props.roomId) { - if (this.props.autoPeek && !this.state.autoPeekDone) { - var Loader = sdk.getComponent("elements.Spinner"); + if (this.state.timelineLoading) { return (
    @@ -1529,7 +1647,6 @@ module.exports = React.createClass({ var myMember = this.state.room.getMember(myUserId); if (myMember && myMember.membership == 'invite') { if (this.state.joining || this.state.rejecting) { - var Loader = sdk.getComponent("elements.Spinner"); return (
    @@ -1640,7 +1757,7 @@ module.exports = React.createClass({ // set when you've scrolled up else if (unreadMsgs) { statusBar = ( -
    +
    {unreadMsgs}
    @@ -1654,9 +1771,9 @@ module.exports = React.createClass({
    ); } - else if (!this.state.atBottom) { + else if (!this.state.atEndOfLiveTimeline) { statusBar = ( -
    +
    Scroll to bottom of page
    ); @@ -1668,7 +1785,6 @@ module.exports = React.createClass({ aux = ; } else if (this.state.uploadingRoomSettings) { - var Loader = sdk.getComponent("elements.Spinner"); aux = ; } else if (this.state.searching) { @@ -1794,15 +1910,40 @@ module.exports = React.createClass({ hideMessagePanel = true; } - var messagePanel = ( + 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 { + // it's important that stickyBottom = false on this, 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. + messagePanel = ( + style={ hideMessagePanel ? { display: 'none' } : {} } + stickyBottom={ false }>
  • {this.getEventTiles()}
    - ); + ); + } return (
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index e19b041219..514937f877 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -37,14 +37,32 @@ if (DEBUG_SCROLL) { * It also provides a hook which allows parents to provide more list elements * when we get close to the start or end of the list. * - * We don't save the absolute scroll offset, because that would be affected by - * window width, zoom level, amount of scrollback, etc. Instead we save an - * identifier for the last fully-visible message, and the number of pixels the - * window was scrolled below it - which is hopefully be near enough. - * * Each child element should have a 'data-scroll-token'. This token is used to - * serialise the scroll state, and returned as the 'lastDisplayedScrollToken' + * serialise the scroll state, and returned as the 'trackedScrollToken' * attribute by getScrollState(). + * + * Some notes about the implementation: + * + * The saved 'scrollState' can exist in one of two states: + * + * - stuckAtBottom: (the default, and restored by resetScrollState): the + * viewport is scrolled down as far as it can be. When the children are + * updated, the scroll position will be updated to ensure it is still at + * the bottom. + * + * - fixed, in which the viewport is conceptually tied at a specific scroll + * offset. We don't save the absolute scroll offset, because that would be + * affected by window width, zoom level, amount of scrollback, etc. Instead + * we save an identifier for the last fully-visible message, and the number + * of pixels the window was scrolled below it - which is hopefully be near + * enough. + * + * The 'stickyBottom' property controls the behaviour when we reach the bottom + * of the window (either through a user-initiated scroll, or by calling + * scrollToBottom). If stickyBottom is enabled, the scrollState will enter + * 'stuckAtBottom' state - ensuring that new additions cause the window to + * scroll down further. If stickyBottom is disabled, we just save the scroll + * offset as normal. */ module.exports = React.createClass({ displayName: 'ScrollPanel', @@ -145,8 +163,15 @@ module.exports = React.createClass({ this.recentEventScroll = undefined; } - this.scrollState = this._calculateScrollState(); - debuglog("Saved scroll state", this.scrollState); + // If there weren't enough children to fill the viewport, the scroll we + // got might be different to the scroll we wanted; we don't want to + // forget what we wanted, so don't overwrite the saved state unless + // this appears to be a user-initiated scroll. + if (sn.scrollTop != this._lastSetScroll) { + this._saveScrollState(); + } else { + debuglog("Ignoring scroll echo"); + } this.props.onScroll(ev); @@ -155,13 +180,19 @@ module.exports = React.createClass({ // return true if the content is fully scrolled down right now; else false. // - // Note that if the content hasn't yet been fully populated, this may - // spuriously return true even if the user wanted to be looking at earlier - // content. So don't call it in render() cycles. + // note that this is independent of the 'stuckAtBottom' state - it is simply + // about whether the the content is scrolled down right now, irrespective of + // whether it will stay that way when the children update. isAtBottom: function() { var sn = this._getScrollNode(); - // + 2 here to avoid fractional pixel rounding errors - return sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 2; + + // there seems to be some bug with flexbox/gemini/chrome/richvdh's + // understanding of the box model, wherein the scrollNode ends up 2 + // pixels higher than the available space, even when there are less + // than a screenful of messages. + 3 is a fudge factor to pretend + // that we're at the bottom when we're still a few pixels off. + + return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; }, // check the scroll state and send out backfill requests if necessary. @@ -230,9 +261,9 @@ module.exports = React.createClass({ } q.finally(fillPromise, () => { - debuglog("ScrollPanel: "+dir+" fill complete"); this._pendingFillRequests[dir] = false; }).then((hasMoreResults) => { + debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); if (hasMoreResults) { // further pagination requests have been disabled until now, so // it's time to check the fill state again in case the pagination @@ -242,42 +273,83 @@ module.exports = React.createClass({ }).done(); }, - // get the current scroll position of the room, so that it can be - // restored later + /* get the current scroll state. This returns an object with the following + * properties: + * + * boolean stuckAtBottom: true if we are tracking the bottom of the + * scroll. false if we are tracking a particular child. + * + * string trackedScrollToken: undefined if stuckAtBottom is true; if it is + * false, the data-scroll-token of the child which we are tracking. + * + * number pixelOffset: undefined if stuckAtBottom is true; if it is false, + * the number of pixels the bottom of the tracked child is above the + * bottom of the scroll panel. + */ getScrollState: function() { return this.scrollState; }, /* reset the saved scroll state. - * - * This will cause the scroll to be reinitialised on the next update of the - * child list. * * This is useful if the list is being replaced, and you don't want to * preserve scroll even if new children happen to have the same scroll * tokens as old ones. + * + * This will cause the viewport to be scrolled down to the bottom on the + * next update of the child list. This is different to scrollToBottom(), + * which would save the current bottom-most child as the active one (so is + * no use if no children exist yet, or if you are about to replace the + * child list.) */ resetScrollState: function() { - this.scrollState = null; - }, - - scrollToTop: function() { - this._getScrollNode().scrollTop = 0; - debuglog("Scrolled to top"); + this.scrollState = {stuckAtBottom: true}; }, scrollToBottom: function() { + // the easiest way to make sure that the scroll state is correctly + // saved is to do the scroll, then save the updated state. (Calculating + // it ourselves is hard, and we can't rely on an onScroll callback + // happening, since there may be no user-visible change here). var scrollNode = this._getScrollNode(); + scrollNode.scrollTop = scrollNode.scrollHeight; debuglog("Scrolled to bottom; offset now", scrollNode.scrollTop); + this._lastSetScroll = scrollNode.scrollTop; + + this._saveScrollState(); }, - // scroll the message list to the node with the given scrollToken. See - // notes in _calculateScrollState on how this works. - // - // pixel_offset gives the number of pixels between the bottom of the node - // and the bottom of the container. + // 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. scrollToToken: function(scrollToken, pixelOffset) { + var scrollNode = this._getScrollNode(); + + // default to the middle + if (pixelOffset === undefined) { + pixelOffset = scrollNode.clientHeight / 2; + } + + // save the desired scroll state. It's important we do this here rather + // than as a result of the scroll event, because (a) we might not *get* + // a scroll event, and (b) it might not currently be possible to set + // the requested scroll state (eg, because we hit the end of the + // timeline and need to do more pagination); we want to save the + // *desired* scroll state rather than what we end up achieving. + this.scrollState = { + stuckAtBottom: false, + trackedScrollToken: scrollToken, + pixelOffset: pixelOffset + }; + + // ... then make it so. + this._restoreSavedScrollState(); + }, + + // set the scrollTop attribute appropriately to position the given child at the + // given offset in the window. A helper for _restoreSavedScrollState. + _scrollToToken: function(scrollToken, pixelOffset) { /* find the dom node with the right scrolltoken */ var node; var messages = this.refs.itemlist.children; @@ -291,7 +363,7 @@ module.exports = React.createClass({ } if (!node) { - console.error("No node with scrollToken '"+scrollToken+"'"); + debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'"); return; } @@ -312,15 +384,12 @@ module.exports = React.createClass({ debuglog("recentEventScroll now "+this.recentEventScroll); }, - _calculateScrollState: function() { - // Our scroll implementation is agnostic of the precise contents of the - // message list (since it needs to work with both search results and - // timelines). 'refs.messageList' is expected to be a DOM node with a - // number of children, each of which may have a 'data-scroll-token' - // attribute. It is this token which is stored as the - // 'lastDisplayedScrollToken'. - - var atBottom = this.isAtBottom(); + _saveScrollState: function() { + if (this.props.stickyBottom && this.isAtBottom()) { + this.scrollState = { stuckAtBottom: true }; + debuglog("Saved scroll state", this.scrollState); + return; + } var itemlist = this.refs.itemlist; var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); @@ -332,28 +401,34 @@ module.exports = React.createClass({ var boundingRect = node.getBoundingClientRect(); if (boundingRect.bottom < wrapperRect.bottom) { - return { - atBottom: atBottom, - lastDisplayedScrollToken: node.dataset.scrollToken, + this.scrollState = { + stuckAtBottom: false, + trackedScrollToken: node.dataset.scrollToken, pixelOffset: wrapperRect.bottom - boundingRect.bottom, } + debuglog("Saved scroll state", this.scrollState); + return; } } - // apparently the entire timeline is below the viewport. Give up. - return { atBottom: true }; + debuglog("Unable to save scroll state: found no children in the viewport"); }, _restoreSavedScrollState: function() { var scrollState = this.scrollState; - if (!scrollState || (this.props.stickyBottom && scrollState.atBottom)) { - this.scrollToBottom(); - } else if (scrollState.lastDisplayedScrollToken) { - this.scrollToToken(scrollState.lastDisplayedScrollToken, + var scrollNode = this._getScrollNode(); + + if (scrollState.stuckAtBottom) { + scrollNode.scrollTop = scrollNode.scrollHeight; + debuglog("Scrolled to bottom; offset now", scrollNode.scrollTop); + } else if (scrollState.trackedScrollToken) { + this._scrollToToken(scrollState.trackedScrollToken, scrollState.pixelOffset); } + this._lastSetScroll = scrollNode.scrollTop; }, + /* get the DOM node which has the scrollTop property we care about for our * message panel. */ diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index 741f0ebc69..fe80a0d61b 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -58,7 +58,7 @@ module.exports = React.createClass({ onHomeserverChanged: function(ev) { this.setState({hs_url: ev.target.value}, function() { this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { - this.props.onHsUrlChanged(this.state.hs_url); + this.props.onHsUrlChanged(this.state.hs_url.replace(/\/$/, "")); }); }); }, @@ -66,7 +66,7 @@ module.exports = React.createClass({ onIdentityServerChanged: function(ev) { this.setState({is_url: ev.target.value}, function() { this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() { - this.props.onIsUrlChanged(this.state.is_url); + this.props.onIsUrlChanged(this.state.is_url.replace(/\/$/, "")); }); }); }, diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index cfac6cc9b2..f580686128 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -98,6 +98,9 @@ module.exports = React.createClass({ /* a function to be called when the highlight is clicked */ onHighlightClick: React.PropTypes.func, + + /* is this the focussed event */ + isSelectedEvent: React.PropTypes.bool, }, getInitialState: function() { @@ -273,6 +276,7 @@ module.exports = React.createClass({ ) !== -1, mx_EventTile_notSent: this.props.mxEvent.status == 'not_sent', mx_EventTile_highlight: this.shouldHighlight(), + mx_EventTile_selected: this.props.isSelectedEvent, mx_EventTile_continuation: this.props.continuation, mx_EventTile_last: this.props.last, mx_EventTile_contextual: this.props.contextual,