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 = (
-
+
);
@@ -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,