Merge branch 'develop' into matthew/settings
This commit is contained in:
commit
6295cf2ec9
7 changed files with 977 additions and 401 deletions
|
@ -23,7 +23,6 @@ limitations under the License.
|
|||
|
||||
var React = require("react");
|
||||
var ReactDOM = require("react-dom");
|
||||
var GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
var q = require("q");
|
||||
var classNames = require("classnames");
|
||||
var Matrix = require("matrix-js-sdk");
|
||||
|
@ -34,6 +33,8 @@ var WhoIsTyping = require("../../WhoIsTyping");
|
|||
var Modal = require("../../Modal");
|
||||
var sdk = require('../../index');
|
||||
var CallHandler = require('../../CallHandler');
|
||||
var TabComplete = require("../../TabComplete");
|
||||
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
|
||||
var Resend = require("../../Resend");
|
||||
var dis = require("../../dispatcher");
|
||||
|
||||
|
@ -49,13 +50,6 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
/* properties in RoomView objects include:
|
||||
*
|
||||
* savedScrollState: the current scroll position in the backlog. Response
|
||||
* from _calculateScrollState. Updated on scroll events.
|
||||
*
|
||||
* savedSearchScrollState: similar to savedScrollState, but specific to the
|
||||
* search results (we need to preserve savedScrollState when search
|
||||
* results are visible)
|
||||
*
|
||||
* eventNodes: a map from event id to DOM node representing that event
|
||||
*/
|
||||
|
@ -84,7 +78,18 @@ module.exports = React.createClass({
|
|||
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
|
||||
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
|
||||
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
|
||||
this.savedScrollState = {atBottom: true};
|
||||
// xchat-style tab complete, add a colon if tab
|
||||
// completing at the start of the text
|
||||
this.tabComplete = new TabComplete({
|
||||
startingWordSuffix: ": ",
|
||||
wordSuffix: " ",
|
||||
allowLooping: false,
|
||||
autoEnterTabComplete: true,
|
||||
onClickCompletes: true,
|
||||
onStateChange: (isCompleting) => {
|
||||
this.forceUpdate();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -168,23 +173,6 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
// get the DOM node which has the scrollTop property we care about for our
|
||||
// message panel.
|
||||
//
|
||||
// If the gemini scrollbar is doing its thing, this will be a div within
|
||||
// the message panel (ie, the gemini container); otherwise it will be the
|
||||
// message panel itself.
|
||||
_getScrollNode: function() {
|
||||
var panel = ReactDOM.findDOMNode(this.refs.messagePanel);
|
||||
if (!panel) return null;
|
||||
|
||||
if (panel.classList.contains('gm-prevented')) {
|
||||
return panel;
|
||||
} else {
|
||||
return panel.children[2]; // XXX: Fragile!
|
||||
}
|
||||
},
|
||||
|
||||
onSyncStateChange: function(state, prevState) {
|
||||
if (state === "SYNCING" && prevState === "SYNCING") {
|
||||
return;
|
||||
|
@ -218,7 +206,7 @@ module.exports = React.createClass({
|
|||
if (!toStartOfTimeline &&
|
||||
(ev.getSender() !== MatrixClientPeg.get().credentials.userId)) {
|
||||
// update unread count when scrolled up
|
||||
if (!this.state.searchResults && this.savedScrollState.atBottom) {
|
||||
if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) {
|
||||
currentUnread = 0;
|
||||
}
|
||||
else {
|
||||
|
@ -251,6 +239,11 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
onRoomStateMember: function(ev, state, member) {
|
||||
if (member.roomId === this.props.roomId) {
|
||||
// a member state changed in this room, refresh the tab complete list
|
||||
this._updateTabCompleteList(this.state.room);
|
||||
}
|
||||
|
||||
if (!this.props.ConferenceHandler) {
|
||||
return;
|
||||
}
|
||||
|
@ -313,6 +306,17 @@ module.exports = React.createClass({
|
|||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.onResize();
|
||||
|
||||
this._updateTabCompleteList(this.state.room);
|
||||
},
|
||||
|
||||
_updateTabCompleteList: function(room) {
|
||||
if (!room || !this.tabComplete) {
|
||||
return;
|
||||
}
|
||||
this.tabComplete.setCompletionList(
|
||||
MemberEntry.fromMemberList(room.getJoinedMembers())
|
||||
);
|
||||
},
|
||||
|
||||
_initialiseMessagePanel: function() {
|
||||
|
@ -326,7 +330,8 @@ module.exports = React.createClass({
|
|||
|
||||
this.scrollToBottom();
|
||||
this.sendReadReceipt();
|
||||
this.fillSpace();
|
||||
|
||||
this.refs.messagePanel.checkFillState();
|
||||
},
|
||||
|
||||
componentDidUpdate: function() {
|
||||
|
@ -338,11 +343,6 @@ module.exports = React.createClass({
|
|||
if (!this.refs.messagePanel.initialised) {
|
||||
this._initialiseMessagePanel();
|
||||
}
|
||||
|
||||
// after adding event tiles, we may need to tweak the scroll (either to
|
||||
// keep at the bottom of the timeline, or to maintain the view after
|
||||
// adding events to the top).
|
||||
this._restoreSavedScrollState();
|
||||
},
|
||||
|
||||
_paginateCompleted: function() {
|
||||
|
@ -352,37 +352,32 @@ module.exports = React.createClass({
|
|||
room: MatrixClientPeg.get().getRoom(this.props.roomId)
|
||||
});
|
||||
|
||||
// we might not have got enough results from the pagination
|
||||
// request, so give fillSpace() a chance to set off another.
|
||||
this.setState({paginating: false});
|
||||
|
||||
if (!this.state.searchResults) {
|
||||
this.fillSpace();
|
||||
// we might not have got enough (or, indeed, any) results from the
|
||||
// pagination request, so give the messagePanel a chance to set off
|
||||
// another.
|
||||
|
||||
this.refs.messagePanel.checkFillState();
|
||||
},
|
||||
|
||||
onSearchResultsFillRequest: function(backwards) {
|
||||
if (!backwards || this.state.searchInProgress)
|
||||
return;
|
||||
|
||||
if (this.nextSearchBatch) {
|
||||
if (DEBUG_SCROLL) console.log("requesting more search results");
|
||||
this._getSearchBatch(this.state.searchTerm,
|
||||
this.state.searchScope);
|
||||
} else {
|
||||
if (DEBUG_SCROLL) console.log("no more search results");
|
||||
}
|
||||
},
|
||||
|
||||
// check the scroll position, and if we need to, set off a pagination
|
||||
// request.
|
||||
fillSpace: function() {
|
||||
if (!this.refs.messagePanel) return;
|
||||
var messageWrapperScroll = this._getScrollNode();
|
||||
if (messageWrapperScroll.scrollTop > messageWrapperScroll.clientHeight) {
|
||||
// set off a pagination request.
|
||||
onMessageListFillRequest: function(backwards) {
|
||||
if (!backwards || this.state.paginating)
|
||||
return;
|
||||
}
|
||||
|
||||
// there's less than a screenful of messages left - try to get some
|
||||
// more messages.
|
||||
|
||||
if (this.state.searchResults) {
|
||||
if (this.nextSearchBatch) {
|
||||
if (DEBUG_SCROLL) console.log("requesting more search results");
|
||||
this._getSearchBatch(this.state.searchTerm,
|
||||
this.state.searchScope);
|
||||
} else {
|
||||
if (DEBUG_SCROLL) console.log("no more search results");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Either wind back the message cap (if there are enough events in the
|
||||
// timeline to do so), or fire off a pagination request.
|
||||
|
@ -399,6 +394,12 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
// 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;
|
||||
},
|
||||
|
||||
onResendAllClick: function() {
|
||||
var eventsToResend = this._getUnsentMessages(this.state.room);
|
||||
eventsToResend.forEach(function(event) {
|
||||
|
@ -425,44 +426,9 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
onMessageListScroll: function(ev) {
|
||||
var sn = this._getScrollNode();
|
||||
if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
|
||||
|
||||
// Sometimes we see attempts to write to scrollTop essentially being
|
||||
// ignored. (Or rather, it is successfully written, but on the next
|
||||
// scroll event, it's been reset again).
|
||||
//
|
||||
// This was observed on Chrome 47, when scrolling using the trackpad in OS
|
||||
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
|
||||
// due to Chrome not being able to cope with the scroll offset being reset
|
||||
// while a two-finger drag is in progress.
|
||||
//
|
||||
// By way of a workaround, we detect this situation and just keep
|
||||
// resetting scrollTop until we see the scroll node have the right
|
||||
// value.
|
||||
if (this.recentEventScroll !== undefined) {
|
||||
if(sn.scrollTop < this.recentEventScroll-200) {
|
||||
console.log("Working around vector-im/vector-web#528");
|
||||
this._restoreSavedScrollState();
|
||||
return;
|
||||
}
|
||||
this.recentEventScroll = undefined;
|
||||
}
|
||||
|
||||
if (this.refs.messagePanel) {
|
||||
if (this.state.searchResults) {
|
||||
this.savedSearchScrollState = this._calculateScrollState();
|
||||
if (DEBUG_SCROLL) console.log("Saved search scroll state", this.savedSearchScrollState);
|
||||
} else {
|
||||
this.savedScrollState = this._calculateScrollState();
|
||||
if (DEBUG_SCROLL) console.log("Saved scroll state", this.savedScrollState);
|
||||
if (this.savedScrollState.atBottom && this.state.numUnreadMessages != 0) {
|
||||
this.setState({numUnreadMessages: 0});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!this.state.paginating && !this.state.searchInProgress) {
|
||||
this.fillSpace();
|
||||
if (this.state.numUnreadMessages != 0 &&
|
||||
this.refs.messagePanel.isAtBottom()) {
|
||||
this.setState({numUnreadMessages: 0});
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -521,9 +487,15 @@ module.exports = React.createClass({
|
|||
searchResults: [],
|
||||
searchHighlights: [],
|
||||
searchCount: null,
|
||||
searchCanPaginate: null,
|
||||
});
|
||||
|
||||
this.savedSearchScrollState = {atBottom: true};
|
||||
// if we already have a search panel, we need to tell it to forget
|
||||
// about its scroll state.
|
||||
if (this.refs.searchResultsPanel) {
|
||||
this.refs.searchResultsPanel.resetScrollState();
|
||||
}
|
||||
|
||||
this.nextSearchBatch = null;
|
||||
this._getSearchBatch(term, scope);
|
||||
},
|
||||
|
@ -579,6 +551,7 @@ module.exports = React.createClass({
|
|||
searchHighlights: highlights,
|
||||
searchResults: events,
|
||||
searchCount: results.count,
|
||||
searchCanPaginate: !!(results.next_batch),
|
||||
});
|
||||
self.nextSearchBatch = results.next_batch;
|
||||
}, function(error) {
|
||||
|
@ -622,66 +595,88 @@ module.exports = React.createClass({
|
|||
}
|
||||
},
|
||||
|
||||
getEventTiles: function() {
|
||||
getSearchResultTiles: function() {
|
||||
var DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
var cli = MatrixClientPeg.get();
|
||||
|
||||
var ret = [];
|
||||
|
||||
var EventTile = sdk.getComponent('rooms.EventTile');
|
||||
|
||||
// XXX: todo: merge overlapping results somehow?
|
||||
// XXX: why doesn't searching on name work?
|
||||
|
||||
|
||||
if (this.state.searchCanPaginate === false) {
|
||||
if (this.state.searchResults.length == 0) {
|
||||
ret.push(<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">No results</h2>
|
||||
</li>
|
||||
);
|
||||
} else {
|
||||
ret.push(<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">No more results</h2>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var lastRoomId;
|
||||
|
||||
for (var i = this.state.searchResults.length - 1; i >= 0; i--) {
|
||||
var result = this.state.searchResults[i];
|
||||
var mxEv = new Matrix.MatrixEvent(result.result);
|
||||
|
||||
if (!EventTile.haveTileForEvent(mxEv)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
}
|
||||
|
||||
var eventId = mxEv.getId();
|
||||
|
||||
if (this.state.searchScope === 'All') {
|
||||
var roomId = result.result.room_id;
|
||||
if(roomId != lastRoomId) {
|
||||
ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>);
|
||||
lastRoomId = roomId;
|
||||
}
|
||||
}
|
||||
|
||||
var ts1 = result.result.origin_server_ts;
|
||||
ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank}
|
||||
|
||||
if (result.context.events_before[0]) {
|
||||
var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]);
|
||||
if (EventTile.haveTileForEvent(mxEv2)) {
|
||||
ret.push(<li key={eventId+"-1"} data-scroll-token={eventId+"-1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
|
||||
}
|
||||
}
|
||||
|
||||
ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={this.state.searchHighlights}/></li>);
|
||||
|
||||
if (result.context.events_after[0]) {
|
||||
var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]);
|
||||
if (EventTile.haveTileForEvent(mxEv2)) {
|
||||
ret.push(<li key={eventId+"+1"} data-scroll-token={eventId+"+1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
},
|
||||
|
||||
getEventTiles: function() {
|
||||
var DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||
|
||||
var ret = [];
|
||||
var count = 0;
|
||||
|
||||
var EventTile = sdk.getComponent('rooms.EventTile');
|
||||
var self = this;
|
||||
|
||||
if (this.state.searchResults)
|
||||
{
|
||||
// XXX: todo: merge overlapping results somehow?
|
||||
// XXX: why doesn't searching on name work?
|
||||
|
||||
var lastRoomId;
|
||||
|
||||
for (var i = this.state.searchResults.length - 1; i >= 0; i--) {
|
||||
var result = this.state.searchResults[i];
|
||||
var mxEv = new Matrix.MatrixEvent(result.result);
|
||||
|
||||
if (!EventTile.haveTileForEvent(mxEv)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
}
|
||||
|
||||
var eventId = mxEv.getId();
|
||||
|
||||
if (self.state.searchScope === 'All') {
|
||||
var roomId = result.result.room_id;
|
||||
if(roomId != lastRoomId) {
|
||||
ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>);
|
||||
lastRoomId = roomId;
|
||||
}
|
||||
}
|
||||
|
||||
var ts1 = result.result.origin_server_ts;
|
||||
ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank}
|
||||
|
||||
if (result.context.events_before[0]) {
|
||||
var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]);
|
||||
if (EventTile.haveTileForEvent(mxEv2)) {
|
||||
ret.push(<li key={eventId+"-1"} data-scroll-token={eventId+"-1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
|
||||
}
|
||||
}
|
||||
|
||||
ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={self.state.searchHighlights}/></li>);
|
||||
|
||||
if (result.context.events_after[0]) {
|
||||
var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]);
|
||||
if (EventTile.haveTileForEvent(mxEv2)) {
|
||||
ret.push(<li key={eventId+"+1"} data-scroll-token={eventId+"+1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
for (var i = this.state.room.timeline.length-1; i >= 0 && count < this.state.messageCap; --i) {
|
||||
var prevEvent = null; // the last event we showed
|
||||
var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap);
|
||||
for (var i = startIdx; i < this.state.room.timeline.length; i++) {
|
||||
var mxEv = this.state.room.timeline[i];
|
||||
|
||||
if (!EventTile.haveTileForEvent(mxEv)) {
|
||||
|
@ -694,49 +689,45 @@ module.exports = React.createClass({
|
|||
}
|
||||
}
|
||||
|
||||
// is this a continuation of the previous message?
|
||||
var continuation = false;
|
||||
var last = false;
|
||||
var dateSeparator = null;
|
||||
if (i == this.state.room.timeline.length - 1) {
|
||||
last = true;
|
||||
}
|
||||
if (i > 0 && count < this.state.messageCap - 1) {
|
||||
if (this.state.room.timeline[i].sender &&
|
||||
this.state.room.timeline[i - 1].sender &&
|
||||
(this.state.room.timeline[i].sender.userId ===
|
||||
this.state.room.timeline[i - 1].sender.userId) &&
|
||||
(this.state.room.timeline[i].getType() ==
|
||||
this.state.room.timeline[i - 1].getType())
|
||||
if (prevEvent !== null) {
|
||||
if (mxEv.sender &&
|
||||
prevEvent.sender &&
|
||||
(mxEv.sender.userId === prevEvent.sender.userId) &&
|
||||
(mxEv.getType() == prevEvent.getType())
|
||||
)
|
||||
{
|
||||
continuation = true;
|
||||
}
|
||||
|
||||
var ts0 = this.state.room.timeline[i - 1].getTs();
|
||||
var ts1 = this.state.room.timeline[i].getTs();
|
||||
if (new Date(ts0).toDateString() !== new Date(ts1).toDateString()) {
|
||||
dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
|
||||
continuation = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (i === 1) { // n.b. 1, not 0, as the 0th event is an m.room.create and so doesn't show on the timeline
|
||||
var ts1 = this.state.room.timeline[i].getTs();
|
||||
dateSeparator = <li key={ts1}><DateSeparator ts={ts1}/></li>;
|
||||
// do we need a date separator since the last event?
|
||||
var ts1 = mxEv.getTs();
|
||||
if ((prevEvent == null && !this._canPaginate()) ||
|
||||
(prevEvent != null &&
|
||||
new Date(prevEvent.getTs()).toDateString() !== new Date(ts1).toDateString())) {
|
||||
var dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
|
||||
ret.push(dateSeparator);
|
||||
continuation = false;
|
||||
}
|
||||
|
||||
var last = false;
|
||||
if (i == this.state.room.timeline.length - 1) {
|
||||
// XXX: we might not show a tile for the last event.
|
||||
last = true;
|
||||
}
|
||||
|
||||
var eventId = mxEv.getId();
|
||||
ret.unshift(
|
||||
ret.push(
|
||||
<li key={eventId} ref={this._collectEventNode.bind(this, eventId)} data-scroll-token={eventId}>
|
||||
<EventTile mxEvent={mxEv} continuation={continuation} last={last}/>
|
||||
</li>
|
||||
);
|
||||
if (dateSeparator) {
|
||||
ret.unshift(dateSeparator);
|
||||
}
|
||||
++count;
|
||||
|
||||
prevEvent = mxEv;
|
||||
}
|
||||
|
||||
return ret;
|
||||
},
|
||||
|
||||
|
@ -975,10 +966,9 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
scrollToBottom: function() {
|
||||
var scrollNode = this._getScrollNode();
|
||||
if (!scrollNode) return;
|
||||
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||
if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop);
|
||||
var messagePanel = this.refs.messagePanel;
|
||||
if (!messagePanel) return;
|
||||
messagePanel.scrollToBottom();
|
||||
},
|
||||
|
||||
// scroll the event view to put the given event at the bottom.
|
||||
|
@ -986,6 +976,9 @@ module.exports = React.createClass({
|
|||
// 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
|
||||
|
@ -995,7 +988,7 @@ module.exports = React.createClass({
|
|||
//
|
||||
// for now, just scroll to the top of the buffer.
|
||||
console.log("Refusing to scroll to unknown event "+eventId);
|
||||
this._getScrollNode().scrollTop = 0;
|
||||
messagePanel.scrollToTop();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -1015,117 +1008,30 @@ module.exports = React.createClass({
|
|||
|
||||
// the scrollTokens on our DOM nodes are the event IDs, so we can pass
|
||||
// eventId directly into _scrollToToken.
|
||||
this._scrollToToken(eventId, pixelOffset);
|
||||
},
|
||||
|
||||
_restoreSavedScrollState: function() {
|
||||
var scrollState = this.state.searchResults ? this.savedSearchScrollState : this.savedScrollState;
|
||||
if (!scrollState || scrollState.atBottom) {
|
||||
this.scrollToBottom();
|
||||
} else if (scrollState.lastDisplayedScrollToken) {
|
||||
this._scrollToToken(scrollState.lastDisplayedScrollToken,
|
||||
scrollState.pixelOffset);
|
||||
}
|
||||
},
|
||||
|
||||
_calculateScrollState: function() {
|
||||
// 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
|
||||
// will hopefully be near enough.
|
||||
//
|
||||
// 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 messageWrapperScroll = this._getScrollNode();
|
||||
// + 1 here to avoid fractional pixel rounding errors
|
||||
var atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1;
|
||||
|
||||
var messageWrapper = this.refs.messagePanel;
|
||||
var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
|
||||
var messages = this.refs.messageList.children;
|
||||
|
||||
for (var i = messages.length-1; i >= 0; --i) {
|
||||
var node = messages[i];
|
||||
if (!node.dataset.scrollToken) continue;
|
||||
|
||||
var boundingRect = node.getBoundingClientRect();
|
||||
if (boundingRect.bottom < wrapperRect.bottom) {
|
||||
return {
|
||||
atBottom: atBottom,
|
||||
lastDisplayedScrollToken: node.dataset.scrollToken,
|
||||
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apparently the entire timeline is below the viewport. Give up.
|
||||
return { atBottom: true };
|
||||
},
|
||||
|
||||
// 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.
|
||||
_scrollToToken: function(scrollToken, pixelOffset) {
|
||||
/* find the dom node with the right scrolltoken */
|
||||
var node;
|
||||
var messages = this.refs.messageList.children;
|
||||
for (var i = messages.length-1; i >= 0; --i) {
|
||||
var m = messages[i];
|
||||
if (!m.dataset.scrollToken) continue;
|
||||
if (m.dataset.scrollToken == scrollToken) {
|
||||
node = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
console.error("No node with scrollToken '"+scrollToken+"'");
|
||||
return;
|
||||
}
|
||||
|
||||
var scrollNode = this._getScrollNode();
|
||||
var messageWrapper = this.refs.messagePanel;
|
||||
var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect();
|
||||
var boundingRect = node.getBoundingClientRect();
|
||||
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
|
||||
if(scrollDelta != 0) {
|
||||
scrollNode.scrollTop += scrollDelta;
|
||||
|
||||
// see the comments in onMessageListScroll regarding recentEventScroll
|
||||
this.recentEventScroll = scrollNode.scrollTop;
|
||||
}
|
||||
|
||||
if (DEBUG_SCROLL) {
|
||||
console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")");
|
||||
console.log("recentEventScroll now "+this.recentEventScroll);
|
||||
}
|
||||
messagePanel.scrollToToken(eventId, pixelOffset);
|
||||
},
|
||||
|
||||
// get the current scroll position of the room, so that it can be
|
||||
// restored when we switch back to it
|
||||
getScrollState: function() {
|
||||
return this.savedScrollState;
|
||||
var messagePanel = this.refs.messagePanel;
|
||||
if (!messagePanel) return null;
|
||||
|
||||
return messagePanel.getScrollState();
|
||||
},
|
||||
|
||||
restoreScrollState: function(scrollState) {
|
||||
if (!this.refs.messagePanel) return;
|
||||
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
|
||||
// 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,
|
||||
|
@ -1191,6 +1097,7 @@ module.exports = React.createClass({
|
|||
var CallView = sdk.getComponent("voip.CallView");
|
||||
var RoomSettings = sdk.getComponent("rooms.RoomSettings");
|
||||
var SearchBar = sdk.getComponent("rooms.SearchBar");
|
||||
var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
|
||||
|
||||
if (!this.state.room) {
|
||||
if (this.props.roomId) {
|
||||
|
@ -1277,6 +1184,21 @@ module.exports = React.createClass({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
else if (this.tabComplete.isTabCompleting()) {
|
||||
var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
|
||||
statusBar = (
|
||||
<div className="mx_RoomView_tabCompleteBar">
|
||||
<div className="mx_RoomView_tabCompleteImage">...</div>
|
||||
<div className="mx_RoomView_tabCompleteWrapper">
|
||||
<TabCompleteBar entries={this.tabComplete.peek(6)} />
|
||||
<div className="mx_RoomView_tabCompleteEol">
|
||||
<img src="img/eol.svg" width="22" height="16" alt="->|"/>
|
||||
Auto-complete
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else if (this.state.hasUnsentMessages) {
|
||||
statusBar = (
|
||||
<div className="mx_RoomView_connectionLostBar">
|
||||
|
@ -1357,7 +1279,9 @@ module.exports = React.createClass({
|
|||
);
|
||||
if (canSpeak) {
|
||||
messageComposer =
|
||||
<MessageComposer room={this.state.room} roomView={this} uploadFile={this.uploadFile} callState={this.state.callState} />
|
||||
<MessageComposer
|
||||
room={this.state.room} roomView={this} uploadFile={this.uploadFile}
|
||||
callState={this.state.callState} tabComplete={this.tabComplete} />
|
||||
}
|
||||
|
||||
// TODO: Why aren't we storing the term/scope/count in this format
|
||||
|
@ -1412,6 +1336,33 @@ module.exports = React.createClass({
|
|||
</div>
|
||||
}
|
||||
|
||||
|
||||
// if we have search results, we keep the messagepanel (so that it preserves its
|
||||
// scroll state), but hide it.
|
||||
var searchResultsPanel;
|
||||
var hideMessagePanel = false;
|
||||
|
||||
if (this.state.searchResults) {
|
||||
searchResultsPanel = (
|
||||
<ScrollPanel ref="searchResultsPanel" className="mx_RoomView_messagePanel"
|
||||
onFillRequest={ this.onSearchResultsFillRequest }>
|
||||
<li className={scrollheader_classes}></li>
|
||||
{this.getSearchResultTiles()}
|
||||
</ScrollPanel>
|
||||
);
|
||||
hideMessagePanel = true;
|
||||
}
|
||||
|
||||
var messagePanel = (
|
||||
<ScrollPanel ref="messagePanel" className="mx_RoomView_messagePanel"
|
||||
onScroll={ this.onMessageListScroll }
|
||||
onFillRequest={ this.onMessageListFillRequest }
|
||||
style={ hideMessagePanel ? { display: 'none' } : {} } >
|
||||
<li className={scrollheader_classes}></li>
|
||||
{this.getEventTiles()}
|
||||
</ScrollPanel>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }>
|
||||
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
|
||||
|
@ -1432,15 +1383,8 @@ module.exports = React.createClass({
|
|||
{ conferenceCallNotification }
|
||||
{ aux }
|
||||
</div>
|
||||
<GeminiScrollbar autoshow={true} ref="messagePanel" className="mx_RoomView_messagePanel" onScroll={ this.onMessageListScroll }>
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref="messageList" className="mx_RoomView_MessageList" aria-live="polite">
|
||||
<li className={scrollheader_classes}>
|
||||
</li>
|
||||
{this.getEventTiles()}
|
||||
</ol>
|
||||
</div>
|
||||
</GeminiScrollbar>
|
||||
{ messagePanel }
|
||||
{ searchResultsPanel }
|
||||
<div className="mx_RoomView_statusArea">
|
||||
<div className="mx_RoomView_statusAreaBox">
|
||||
<div className="mx_RoomView_statusAreaBox_line"></div>
|
||||
|
|
283
src/components/structures/ScrollPanel.js
Normal file
283
src/components/structures/ScrollPanel.js
Normal file
|
@ -0,0 +1,283 @@
|
|||
/*
|
||||
Copyright 2015 OpenMarket Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
var React = require("react");
|
||||
var ReactDOM = require("react-dom");
|
||||
var GeminiScrollbar = require('react-gemini-scrollbar');
|
||||
|
||||
var DEBUG_SCROLL = false;
|
||||
|
||||
/* This component implements an intelligent scrolling list.
|
||||
*
|
||||
* It wraps a list of <li> children; when items are added to the start or end
|
||||
* of the list, the scroll position is updated so that the user still sees the
|
||||
* same position in the list.
|
||||
*
|
||||
* 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().
|
||||
*/
|
||||
module.exports = React.createClass({
|
||||
displayName: 'ScrollPanel',
|
||||
|
||||
propTypes: {
|
||||
/* stickyBottom: if set to true, then once the user hits the bottom of
|
||||
* the list, any new children added to the list will cause the list to
|
||||
* scroll down to show the new element, rather than preserving the
|
||||
* existing view.
|
||||
*/
|
||||
stickyBottom: React.PropTypes.bool,
|
||||
|
||||
/* onFillRequest(backwards): a callback which is called on scroll when
|
||||
* the user nears the start (backwards = true) or end (backwards =
|
||||
* false) of the list
|
||||
*/
|
||||
onFillRequest: React.PropTypes.func,
|
||||
|
||||
/* onScroll: a callback which is called whenever any scroll happens.
|
||||
*/
|
||||
onScroll: React.PropTypes.func,
|
||||
|
||||
/* className: classnames to add to the top-level div
|
||||
*/
|
||||
className: React.PropTypes.string,
|
||||
|
||||
/* style: styles to add to the top-level div
|
||||
*/
|
||||
style: React.PropTypes.object,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
return {
|
||||
stickyBottom: true,
|
||||
onFillRequest: function(backwards) {},
|
||||
onScroll: function() {},
|
||||
};
|
||||
},
|
||||
|
||||
componentWillMount: function() {
|
||||
this.resetScrollState();
|
||||
},
|
||||
|
||||
componentDidUpdate: function() {
|
||||
// after adding event tiles, we may need to tweak the scroll (either to
|
||||
// keep at the bottom of the timeline, or to maintain the view after
|
||||
// adding events to the top).
|
||||
this._restoreSavedScrollState();
|
||||
},
|
||||
|
||||
onScroll: function(ev) {
|
||||
var sn = this._getScrollNode();
|
||||
if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
|
||||
|
||||
// Sometimes we see attempts to write to scrollTop essentially being
|
||||
// ignored. (Or rather, it is successfully written, but on the next
|
||||
// scroll event, it's been reset again).
|
||||
//
|
||||
// This was observed on Chrome 47, when scrolling using the trackpad in OS
|
||||
// X Yosemite. Can't reproduce on El Capitan. Our theory is that this is
|
||||
// due to Chrome not being able to cope with the scroll offset being reset
|
||||
// while a two-finger drag is in progress.
|
||||
//
|
||||
// By way of a workaround, we detect this situation and just keep
|
||||
// resetting scrollTop until we see the scroll node have the right
|
||||
// value.
|
||||
if (this.recentEventScroll !== undefined) {
|
||||
if(sn.scrollTop < this.recentEventScroll-200) {
|
||||
console.log("Working around vector-im/vector-web#528");
|
||||
this._restoreSavedScrollState();
|
||||
return;
|
||||
}
|
||||
this.recentEventScroll = undefined;
|
||||
}
|
||||
|
||||
this.scrollState = this._calculateScrollState();
|
||||
if (DEBUG_SCROLL) console.log("Saved scroll state", this.scrollState);
|
||||
|
||||
this.props.onScroll(ev);
|
||||
|
||||
this.checkFillState();
|
||||
},
|
||||
|
||||
isAtBottom: function() {
|
||||
return this.scrollState && this.scrollState.atBottom;
|
||||
},
|
||||
|
||||
// check the scroll state and send out backfill requests if necessary.
|
||||
checkFillState: function() {
|
||||
var sn = this._getScrollNode();
|
||||
|
||||
if (sn.scrollTop < sn.clientHeight) {
|
||||
// there's less than a screenful of messages left - try to get some
|
||||
// more messages.
|
||||
this.props.onFillRequest(true);
|
||||
}
|
||||
},
|
||||
|
||||
// get the current scroll position of the room, so that it can be
|
||||
// restored later
|
||||
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.
|
||||
*/
|
||||
resetScrollState: function() {
|
||||
this.scrollState = null;
|
||||
},
|
||||
|
||||
scrollToTop: function() {
|
||||
this._getScrollNode().scrollTop = 0;
|
||||
if (DEBUG_SCROLL) console.log("Scrolled to top");
|
||||
},
|
||||
|
||||
scrollToBottom: function() {
|
||||
var scrollNode = this._getScrollNode();
|
||||
scrollNode.scrollTop = scrollNode.scrollHeight;
|
||||
if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop);
|
||||
},
|
||||
|
||||
// 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.
|
||||
scrollToToken: function(scrollToken, pixelOffset) {
|
||||
/* find the dom node with the right scrolltoken */
|
||||
var node;
|
||||
var messages = this.refs.itemlist.children;
|
||||
for (var i = messages.length-1; i >= 0; --i) {
|
||||
var m = messages[i];
|
||||
if (!m.dataset.scrollToken) continue;
|
||||
if (m.dataset.scrollToken == scrollToken) {
|
||||
node = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
console.error("No node with scrollToken '"+scrollToken+"'");
|
||||
return;
|
||||
}
|
||||
|
||||
var scrollNode = this._getScrollNode();
|
||||
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
var boundingRect = node.getBoundingClientRect();
|
||||
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom;
|
||||
if(scrollDelta != 0) {
|
||||
scrollNode.scrollTop += scrollDelta;
|
||||
|
||||
// see the comments in onMessageListScroll regarding recentEventScroll
|
||||
this.recentEventScroll = scrollNode.scrollTop;
|
||||
}
|
||||
|
||||
if (DEBUG_SCROLL) {
|
||||
console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")");
|
||||
console.log("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 sn = this._getScrollNode();
|
||||
// + 1 here to avoid fractional pixel rounding errors
|
||||
var atBottom = sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1;
|
||||
|
||||
var itemlist = this.refs.itemlist;
|
||||
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
var messages = itemlist.children;
|
||||
|
||||
for (var i = messages.length-1; i >= 0; --i) {
|
||||
var node = messages[i];
|
||||
if (!node.dataset.scrollToken) continue;
|
||||
|
||||
var boundingRect = node.getBoundingClientRect();
|
||||
if (boundingRect.bottom < wrapperRect.bottom) {
|
||||
return {
|
||||
atBottom: atBottom,
|
||||
lastDisplayedScrollToken: node.dataset.scrollToken,
|
||||
pixelOffset: wrapperRect.bottom - boundingRect.bottom,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apparently the entire timeline is below the viewport. Give up.
|
||||
return { atBottom: true };
|
||||
},
|
||||
|
||||
_restoreSavedScrollState: function() {
|
||||
var scrollState = this.scrollState;
|
||||
if (!scrollState || (this.props.stickyBottom && scrollState.atBottom)) {
|
||||
this.scrollToBottom();
|
||||
} else if (scrollState.lastDisplayedScrollToken) {
|
||||
this.scrollToToken(scrollState.lastDisplayedScrollToken,
|
||||
scrollState.pixelOffset);
|
||||
}
|
||||
},
|
||||
|
||||
/* get the DOM node which has the scrollTop property we care about for our
|
||||
* message panel.
|
||||
*/
|
||||
_getScrollNode: function() {
|
||||
var panel = ReactDOM.findDOMNode(this.refs.geminiPanel);
|
||||
|
||||
// If the gemini scrollbar is doing its thing, this will be a div within
|
||||
// the message panel (ie, the gemini container); otherwise it will be the
|
||||
// message panel itself.
|
||||
|
||||
if (panel.classList.contains('gm-prevented')) {
|
||||
return panel;
|
||||
} else {
|
||||
return panel.children[2]; // XXX: Fragile!
|
||||
}
|
||||
},
|
||||
|
||||
render: function() {
|
||||
// TODO: the classnames on the div and ol could do with being updated to
|
||||
// reflect the fact that we don't necessarily contain a list of messages.
|
||||
// it's not obvious why we have a separate div and ol anyway.
|
||||
return (<GeminiScrollbar autoshow={true} ref="geminiPanel" onScroll={ this.onScroll }
|
||||
className={this.props.className} style={this.props.style}>
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">
|
||||
{this.props.children}
|
||||
</ol>
|
||||
</div>
|
||||
</GeminiScrollbar>
|
||||
);
|
||||
},
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue