Implement direct-to-event linking.
This adds support for links to particular event ids: add /<eventId> to the URL for a room. This commit also ensures that we scroll to the 'read marker' when switching to a room which has no previous scroll state, as well as preventing that marker from going past the middle of the screen. This also reinstates the preservation of scroll state when switching rooms, which was disabled previously.pull/21833/head
parent
f0cf5c0aff
commit
d9e13780b8
|
@ -313,7 +313,7 @@ module.exports = React.createClass({
|
|||
// 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);
|
||||
this._viewRoom(payload.room_id, payload.show_settings, payload.event_id);
|
||||
break;
|
||||
case 'view_prev_room':
|
||||
roomIndexDelta = -1;
|
||||
|
@ -348,7 +348,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;
|
||||
}
|
||||
|
@ -357,7 +358,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;
|
||||
|
@ -429,15 +431,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
|
||||
|
@ -459,15 +480,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);
|
||||
}
|
||||
|
@ -515,9 +535,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;
|
||||
|
@ -635,23 +657,35 @@ module.exports = React.createClass({
|
|||
action: 'start_post_registration',
|
||||
});
|
||||
} else if (screen.indexOf('room/') == 0) {
|
||||
var roomString = screen.split('/')[1];
|
||||
var roomString = screen.substring(5);
|
||||
var eventId;
|
||||
|
||||
// extract event id, if one is given
|
||||
var idx = roomString.indexOf('/');
|
||||
if (idx >= 0) {
|
||||
eventId = roomString.substring(idx+1);
|
||||
roomString = roomString.substring(0, idx);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -828,6 +862,9 @@ module.exports = React.createClass({
|
|||
<RoomView
|
||||
ref="roomView"
|
||||
roomId={this.state.currentRoom}
|
||||
eventId={this.state.initialEventId}
|
||||
highlightedEventId={this.state.highlightedEventId}
|
||||
eventPixelOffset={this.state.initialEventPixelOffset}
|
||||
autoPeek={this.state.autoPeek}
|
||||
key={this.state.currentRoom}
|
||||
ConferenceHandler={this.props.ConferenceHandler} />
|
||||
|
|
|
@ -60,7 +60,20 @@ module.exports = React.createClass({
|
|||
displayName: 'RoomView',
|
||||
propTypes: {
|
||||
ConferenceHandler: React.PropTypes.any,
|
||||
roomId: React.PropTypes.string,
|
||||
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?
|
||||
},
|
||||
|
||||
|
@ -84,7 +97,7 @@ module.exports = React.createClass({
|
|||
syncState: MatrixClientPeg.get().getSyncState(),
|
||||
hasUnsentMessages: this._hasUnsentMessages(room),
|
||||
callState: null,
|
||||
timelineLoaded: false, // track whether our room timeline has loaded
|
||||
timelineLoading: true, // track whether our room timeline is loading
|
||||
guestsCanJoin: false,
|
||||
canPeek: false,
|
||||
readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null,
|
||||
|
@ -153,20 +166,69 @@ module.exports = React.createClass({
|
|||
// Next, load the timeline.
|
||||
roomProm.then((room) => {
|
||||
this._calculatePeekRules(room);
|
||||
this._timelineWindow = new Matrix.TimelineWindow(
|
||||
MatrixClientPeg.get(), room,
|
||||
{windowLimit: TIMELINE_CAP});
|
||||
return this._initTimeline(this.props);
|
||||
}).done();
|
||||
},
|
||||
|
||||
return this._timelineWindow.load(undefined,
|
||||
INITIAL_SIZE);
|
||||
}).then(() => {
|
||||
_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({
|
||||
timelineLoaded: true
|
||||
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();
|
||||
}
|
||||
});
|
||||
}).done();
|
||||
});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -234,10 +296,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 {
|
||||
|
@ -274,11 +332,17 @@ 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");
|
||||
}
|
||||
|
||||
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;
|
||||
|
@ -296,7 +360,7 @@ module.exports = React.createClass({
|
|||
|
||||
if (ev.getSender() !== MatrixClientPeg.get().credentials.userId) {
|
||||
// update unread count when scrolled up
|
||||
if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) {
|
||||
if (!this.state.searchResults && this.state.atBottom) {
|
||||
// no change
|
||||
}
|
||||
else {
|
||||
|
@ -392,6 +456,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.atBottom) {
|
||||
this.refs.messagePanel.scrollToToken(readMarkerEventId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -513,7 +591,6 @@ module.exports = React.createClass({
|
|||
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
|
||||
this.refs.messagePanel.initialised = true;
|
||||
|
||||
this.scrollToBottom();
|
||||
this.sendReadReceipt();
|
||||
|
||||
this.updateTint();
|
||||
|
@ -618,7 +695,8 @@ 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 });
|
||||
}
|
||||
|
@ -912,9 +990,11 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
var eventId = mxEv.getId();
|
||||
var highlight = (eventId == this.props.highlightedEventId);
|
||||
ret.push(
|
||||
<li key={eventId} ref={this._collectEventNode.bind(this, eventId)} data-scroll-token={eventId}>
|
||||
<EventTile mxEvent={mxEv} continuation={continuation} last={last}/>
|
||||
<EventTile mxEvent={mxEv} continuation={continuation}
|
||||
last={last} selectedEvent={highlight}/>
|
||||
</li>
|
||||
);
|
||||
|
||||
|
@ -1199,9 +1279,22 @@ 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.
|
||||
//
|
||||
// For now, let's apply a heuristic: if (a) the server has a
|
||||
// readUpToEvent for us, (b) we can't find it, and (c) we could
|
||||
// forward-paginate the event timeline, then suppress read receipts.
|
||||
if (currentReadUpToEventId && currentReadUpToEventIndex === null &&
|
||||
this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
|
||||
if (lastReadEventIndex === null) return;
|
||||
|
||||
|
@ -1214,6 +1307,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.atBottom) {
|
||||
this.refs.messagePanel.scrollToToken(lastReadEvent.getId());
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -1339,18 +1445,52 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
scrollToBottom: function() {
|
||||
var messagePanel = this.refs.messagePanel;
|
||||
if (!messagePanel) return;
|
||||
messagePanel.scrollToBottom();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 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();
|
||||
|
||||
if (scrollState.atBottom) {
|
||||
// we don't really expect to be in this state, but it will
|
||||
// occasionally happen when we are in a transition. Treat it the
|
||||
// same as having no saved state (which will cause us to scroll to
|
||||
// last unread on reload).
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
focussedEvent: scrollState.lastDisplayedScrollToken,
|
||||
pixelOffset: scrollState.pixelOffset,
|
||||
};
|
||||
},
|
||||
|
||||
onResize: function(e) {
|
||||
|
@ -1444,11 +1584,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._timelineWindow) {
|
||||
if (this.props.roomId) {
|
||||
if (!this.state.timelineLoaded) {
|
||||
var Loader = sdk.getComponent("elements.Spinner");
|
||||
if (this.state.timelineLoading) {
|
||||
return (
|
||||
<div className="mx_RoomView">
|
||||
<Loader />
|
||||
|
@ -1481,7 +1621,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 (
|
||||
<div className="mx_RoomView">
|
||||
<Loader />
|
||||
|
@ -1620,7 +1759,6 @@ module.exports = React.createClass({
|
|||
aux = <RoomSettings ref="room_settings" onSaveClick={this.onSaveClick} onCancelClick={this.onCancelClick} room={this.state.room} />;
|
||||
}
|
||||
else if (this.state.uploadingRoomSettings) {
|
||||
var Loader = sdk.getComponent("elements.Spinner");
|
||||
aux = <Loader/>;
|
||||
}
|
||||
else if (this.state.searching) {
|
||||
|
@ -1746,15 +1884,38 @@ 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 so that the order in the
|
||||
// roomview flexbox is correct.
|
||||
//
|
||||
// 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 = (
|
||||
<div className="mx_RoomView_messagePanel" style={{display: "flex"}}>
|
||||
<Loader />
|
||||
</div>
|
||||
);
|
||||
} 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 = (
|
||||
<ScrollPanel ref="messagePanel" className="mx_RoomView_messagePanel"
|
||||
onScroll={ this.onMessageListScroll }
|
||||
onFillRequest={ this.onMessageListFillRequest }
|
||||
style={ hideMessagePanel ? { display: 'none' } : {} } >
|
||||
style={ hideMessagePanel ? { display: 'none' } : {} }
|
||||
stickyBottom={ false }>
|
||||
<li className={scrollheader_classes}></li>
|
||||
{this.getEventTiles()}
|
||||
</ScrollPanel>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
|
||||
|
|
|
@ -37,14 +37,31 @@ 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'
|
||||
* attribute by getScrollState().
|
||||
*
|
||||
* Some notes about the implementation:
|
||||
*
|
||||
* The saved 'scrollState' can exist in one of two states:
|
||||
*
|
||||
* - atBottom: (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
|
||||
* 'atBottom' 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 +162,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);
|
||||
|
||||
|
@ -154,14 +178,16 @@ 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.
|
||||
isAtBottom: function() {
|
||||
var sn = this._getScrollNode();
|
||||
// + 1 here to avoid fractional pixel rounding errors
|
||||
return sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1;
|
||||
|
||||
// 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 +256,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
|
||||
|
@ -249,35 +275,65 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
/* 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 = {atBottom: 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 = {
|
||||
atBottom: false,
|
||||
lastDisplayedScrollToken: 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 +347,7 @@ module.exports = React.createClass({
|
|||
}
|
||||
|
||||
if (!node) {
|
||||
console.error("No node with scrollToken '"+scrollToken+"'");
|
||||
debuglog("ScrollPanel: No node with scrollToken '"+scrollToken+"'");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -312,15 +368,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 = { atBottom: true };
|
||||
debuglog("Saved scroll state", this.scrollState);
|
||||
return;
|
||||
}
|
||||
|
||||
var itemlist = this.refs.itemlist;
|
||||
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
|
@ -332,28 +385,34 @@ module.exports = React.createClass({
|
|||
|
||||
var boundingRect = node.getBoundingClientRect();
|
||||
if (boundingRect.bottom < wrapperRect.bottom) {
|
||||
return {
|
||||
atBottom: atBottom,
|
||||
this.scrollState = {
|
||||
atBottom: false,
|
||||
lastDisplayedScrollToken: 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();
|
||||
var scrollNode = this._getScrollNode();
|
||||
|
||||
if (scrollState.atBottom) {
|
||||
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||
debuglog("Scrolled to bottom; offset now", scrollNode.scrollTop);
|
||||
} else if (scrollState.lastDisplayedScrollToken) {
|
||||
this.scrollToToken(scrollState.lastDisplayedScrollToken,
|
||||
this._scrollToToken(scrollState.lastDisplayedScrollToken,
|
||||
scrollState.pixelOffset);
|
||||
}
|
||||
this._lastSetScroll = scrollNode.scrollTop;
|
||||
},
|
||||
|
||||
|
||||
/* get the DOM node which has the scrollTop property we care about for our
|
||||
* message panel.
|
||||
*/
|
||||
|
|
|
@ -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 */
|
||||
selectedEvent: 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.selectedEvent,
|
||||
mx_EventTile_continuation: this.props.continuation,
|
||||
mx_EventTile_last: this.props.last,
|
||||
mx_EventTile_contextual: this.props.contextual,
|
||||
|
|
Loading…
Reference in New Issue