Merge PR #126 from matrix-org/rav/link_to_event

Implement direct-to-event linking.
This commit is contained in:
Richard van der Hoff 2016-02-03 14:54:27 +00:00
commit 223675bafb
4 changed files with 395 additions and 107 deletions

View file

@ -320,7 +320,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;
@ -355,7 +355,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;
}
@ -364,7 +365,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;
@ -436,15 +438,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
@ -466,15 +487,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);
}
@ -522,9 +542,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;
@ -650,23 +672,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,
});
}
}
@ -844,6 +871,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} />

View file

@ -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,12 +97,16 @@ 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,
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,
}
},
@ -153,20 +170,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 +300,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 +336,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 +364,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.atEndOfLiveTimeline) {
// no change
}
else {
@ -392,6 +460,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);
}
}
},
@ -513,7 +595,6 @@ module.exports = React.createClass({
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
this.refs.messagePanel.initialised = true;
this.scrollToBottom();
this.sendReadReceipt();
this.updateTint();
@ -618,18 +699,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 });
}
}
},
@ -912,9 +994,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} isSelectedEvent={highlight}/>
</li>
);
@ -1199,9 +1283,29 @@ 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;
@ -1214,6 +1318,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());
}
}
},
@ -1338,19 +1455,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();
// jump down to the bottom of this room, where new events are arriving
jumpToLiveTimeline: function() {
// if we can't forward-paginate the existing timeline, then there
// is no point reloading it - just jump straight to the bottom.
//
// Otherwise, reload the timeline rather than trying to paginate
// through all of space-time.
if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
this._loadTimeline();
} else {
if (this.refs.messagePanel) {
this.refs.messagePanel.scrollToBottom();
}
}
},
// 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.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) {
@ -1444,11 +1600,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 +1637,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 />
@ -1592,7 +1747,7 @@ module.exports = React.createClass({
// set when you've scrolled up
else if (unreadMsgs) {
statusBar = (
<div className="mx_RoomView_unreadMessagesBar" onClick={ this.scrollToBottom }>
<div className="mx_RoomView_unreadMessagesBar" onClick={ this.jumpToLiveTimeline }>
<img src="img/newmessages.svg" width="24" height="24" alt=""/>
{unreadMsgs}
</div>
@ -1606,9 +1761,9 @@ module.exports = React.createClass({
</div>
);
}
else if (!this.state.atBottom) {
else if (!this.state.atEndOfLiveTimeline) {
statusBar = (
<div className="mx_RoomView_scrollToBottomBar" onClick={ this.scrollToBottom }>
<div className="mx_RoomView_scrollToBottomBar" onClick={ this.jumpToLiveTimeline }>
<img src="img/scrolldown.svg" width="24" height="24" alt="Scroll to bottom of page" title="Scroll to bottom of page"/>
</div>
);
@ -1620,7 +1775,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 +1900,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 = (
<div className="mx_RoomView_messagePanel mx_RoomView_messageListWrapper">
<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">

View file

@ -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.
*/

View file

@ -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,