diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index 4d1655872f..adb2ba14b5 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -1,5 +1,5 @@
/*
-Copyright 2015 OpenMarket Ltd
+Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -639,12 +639,30 @@ module.exports = React.createClass({
var rooms = MatrixClientPeg.get().getRooms();
for (var i = 0; i < rooms.length; ++i) {
- notifCount += rooms[i].unread_notification_count;
+ if (rooms[i].unread_notification_count) {
+ notifCount += rooms[i].unread_notification_count;
+ }
}
this.favicon.badge(notifCount);
document.title = (notifCount > 0 ? "["+notifCount+"] " : "")+"Vector";
},
+ onUserSettingsClose: function() {
+ // XXX: use browser history instead to find the previous room?
+ if (this.state.currentRoom) {
+ dis.dispatch({
+ action: 'view_room',
+ room_id: this.state.currentRoom,
+ });
+ }
+ else {
+ dis.dispatch({
+ action: 'view_indexed_room',
+ roomIndex: 0,
+ });
+ }
+ },
+
render: function() {
var LeftPanel = sdk.getComponent('structures.LeftPanel');
var RoomView = sdk.getComponent('structures.RoomView');
@@ -677,7 +695,7 @@ module.exports = React.createClass({
right_panel =
break;
case this.PageTypes.UserSettings:
- page_element =
+ page_element =
right_panel =
break;
case this.PageTypes.CreateRoom:
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 8496d12fe2..177b4ab1cc 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -1,5 +1,5 @@
/*
-Copyright 2015 OpenMarket Ltd
+Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -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");
@@ -42,6 +43,13 @@ var INITIAL_SIZE = 20;
var DEBUG_SCROLL = false;
+if (DEBUG_SCROLL) {
+ // using bind means that we get to keep useful line numbers in the console
+ var debuglog = console.log.bind(console);
+} else {
+ var debuglog = function () {};
+}
+
module.exports = React.createClass({
displayName: 'RoomView',
propTypes: {
@@ -49,13 +57,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 +85,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 +180,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 +213,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 +246,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 +313,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 +337,6 @@ module.exports = React.createClass({
this.scrollToBottom();
this.sendReadReceipt();
- this.fillSpace();
},
componentDidUpdate: function() {
@@ -338,64 +348,52 @@ 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() {
- if (DEBUG_SCROLL) console.log("paginate complete");
+ debuglog("paginate complete");
this.setState({
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();
+ onSearchResultsFillRequest: function(backwards) {
+ if (!backwards)
+ return q(false);
+
+ if (this.state.searchResults.next_batch) {
+ debuglog("requesting more search results");
+ var searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch(
+ this.state.searchResults);
+ return this._handleSearchResult(searchPromise);
+ } else {
+ debuglog("no more search results");
+ return q(false);
}
},
- // 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) {
- 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;
- }
+ // set off a pagination request.
+ onMessageListFillRequest: function(backwards) {
+ if (!backwards)
+ return q(false);
// Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request.
if (this.state.messageCap < this.state.room.timeline.length) {
var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length);
- if (DEBUG_SCROLL) console.log("winding back message cap to", cap);
+ debuglog("winding back message cap to", cap);
this.setState({messageCap: cap});
+ return q(true);
} else if(this.state.room.oldState.paginationToken) {
var cap = this.state.messageCap + PAGINATE_SIZE;
- if (DEBUG_SCROLL) console.log("starting paginate to cap", cap);
+ debuglog("starting paginate to cap", cap);
this.setState({messageCap: cap, paginating: true});
- MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done();
+ return MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).
+ finally(this._paginateCompleted).then(true);
}
},
@@ -431,44 +429,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});
}
},
@@ -524,71 +487,78 @@ module.exports = React.createClass({
this.setState({
searchTerm: term,
searchScope: scope,
- searchResults: [],
+ searchResults: {},
searchHighlights: [],
- searchCount: null,
- searchCanPaginate: null,
});
- this.savedSearchScrollState = {atBottom: true};
- this.nextSearchBatch = null;
- this._getSearchBatch(term, scope);
+ // 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();
+ }
+
+ // make sure that we don't end up showing results from
+ // an aborted search by keeping a unique id.
+ //
+ // todo: should cancel any previous search requests.
+ this.searchId = new Date().getTime();
+
+ var filter;
+ if (scope === "Room") {
+ filter = {
+ // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
+ rooms: [
+ this.props.roomId
+ ]
+ };
+ }
+
+ debuglog("sending search request");
+
+ var searchPromise = MatrixClientPeg.get().searchRoomEvents({
+ filter: filter,
+ term: term,
+ });
+ this._handleSearchResult(searchPromise).done();
},
- // fire off a request for a batch of search results
- _getSearchBatch: function(term, scope) {
+ _handleSearchResult: function(searchPromise) {
+ var self = this;
+
+ // keep a record of the current search id, so that if the search terms
+ // change before we get a response, we can ignore the results.
+ var localSearchId = this.searchId;
+
this.setState({
searchInProgress: true,
});
- // make sure that we don't end up merging results from
- // different searches by keeping a unique id.
- //
- // todo: should cancel any previous search requests.
- var searchId = this.searchId = new Date().getTime();
-
- var self = this;
-
- if (DEBUG_SCROLL) console.log("sending search request");
- MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope),
- next_batch: this.nextSearchBatch })
- .then(function(data) {
- if (DEBUG_SCROLL) console.log("search complete");
- if (!self.state.searching || self.searchId != searchId) {
+ return searchPromise.then(function(results) {
+ debuglog("search complete");
+ if (!self.state.searching || self.searchId != localSearchId) {
console.error("Discarding stale search results");
return;
}
- var results = data.search_categories.room_events;
+ // postgres on synapse returns us precise details of the strings
+ // which actually got matched for highlighting.
+ //
+ // In either case, we want to highlight the literal search term
+ // whether it was used by the search engine or not.
- // postgres on synapse returns us precise details of the
- // strings which actually got matched for highlighting.
-
- // combine the highlight list with our existing list; build an object
- // to avoid O(N^2) fail
- var highlights = {};
- results.highlights.forEach(function(hl) { highlights[hl] = 1; });
- self.state.searchHighlights.forEach(function(hl) { highlights[hl] = 1; });
-
- // turn it back into an ordered list. For overlapping highlights,
- // favour longer (more specific) terms first
- highlights = Object.keys(highlights).sort(function(a, b) { b.length - a.length });
-
- // sqlite doesn't give us any highlights, so just try to highlight the literal search term
- if (highlights.length == 0) {
- highlights = [ term ];
+ var highlights = results.highlights;
+ if (highlights.indexOf(self.state.searchTerm) < 0) {
+ highlights = highlights.concat(self.state.searchTerm);
}
- // append the new results to our existing results
- var events = self.state.searchResults.concat(results.results);
+ // For overlapping highlights,
+ // favour longer (more specific) terms first
+ highlights = highlights.sort(function(a, b) { b.length - a.length });
self.setState({
searchHighlights: highlights,
- searchResults: events,
- searchCount: results.count,
- searchCanPaginate: !!(results.next_batch),
+ searchResults: results,
});
- self.nextSearchBatch = results.next_batch;
}, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
@@ -599,109 +569,84 @@ module.exports = React.createClass({
self.setState({
searchInProgress: false
});
- }).done();
+ });
},
- _getSearchCondition: function(term, scope) {
- var filter;
- if (scope === "Room") {
- filter = {
- // XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
- rooms: [
- this.props.roomId
- ]
- };
+ getSearchResultTiles: function() {
+ var EventTile = sdk.getComponent('rooms.EventTile');
+ var SearchResultTile = sdk.getComponent('rooms.SearchResultTile');
+ var cli = MatrixClientPeg.get();
+
+ // XXX: todo: merge overlapping results somehow?
+ // XXX: why doesn't searching on name work?
+
+ if (this.state.searchResults.results === undefined) {
+ // awaiting results
+ return [];
}
- return {
- search_categories: {
- room_events: {
- search_term: term,
- filter: filter,
- order_by: "recent",
- event_context: {
- before_limit: 1,
- after_limit: 1,
- include_profile: true,
- }
- }
+ var ret = [];
+
+ if (!this.state.searchResults.next_batch) {
+ if (this.state.searchResults.results.length == 0) {
+ ret.push(
+ No results
+
+ );
+ } else {
+ ret.push(
+ No more results
+
+ );
}
}
+
+ var lastRoomId;
+
+ for (var i = this.state.searchResults.results.length - 1; i >= 0; i--) {
+ var result = this.state.searchResults.results[i];
+
+ var mxEv = result.context.getEvent();
+
+ if (!EventTile.haveTileForEvent(mxEv)) {
+ // XXX: can this ever happen? It will make the result count
+ // not match the displayed count.
+ continue;
+ }
+
+ if (this.state.searchScope === 'All') {
+ var roomId = mxEv.getRoomId();
+ if(roomId != lastRoomId) {
+ var room = cli.getRoom(roomId);
+
+ // XXX: if we've left the room, we might not know about
+ // it. We should tell the js sdk to go and find out about
+ // it. But that's not an issue currently, as synapse only
+ // returns results for rooms we're joined to.
+ var roomName = room ? room.name : "Unknown room "+roomId;
+
+ ret.push(
+ Room: { roomName }
+ );
+ lastRoomId = roomId;
+ }
+ }
+
+ ret.push();
+ }
+ return ret;
},
getEventTiles: function() {
var DateSeparator = sdk.getComponent('messages.DateSeparator');
- var cli = MatrixClientPeg.get();
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;
-
- if (this.state.searchCanPaginate === false) {
- if (this.state.searchResults.length == 0) {
- ret.push(
- No results
-
- );
- } else {
- ret.push(
- No more results
-
- );
- }
- }
-
- 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(Room: { cli.getRoom(roomId).name }
);
- lastRoomId = roomId;
- }
- }
-
- var ts1 = result.result.origin_server_ts;
- ret.push(); // 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();
- }
- }
-
- ret.push();
-
- if (result.context.events_after[0]) {
- var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]);
- if (EventTile.haveTileForEvent(mxEv2)) {
- ret.push();
- }
- }
- }
- return ret;
- }
var prevEvent = null; // the last event we showed
@@ -996,10 +941,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.
@@ -1007,6 +951,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
@@ -1016,7 +963,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;
}
@@ -1036,117 +983,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,
@@ -1212,6 +1072,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) {
@@ -1298,6 +1159,21 @@ module.exports = React.createClass({
);
}
+ else if (this.tabComplete.isTabCompleting()) {
+ var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
+ statusBar = (
+
+
...
+
+
+
+

+ Auto-complete
+
+
+
+ );
+ }
else if (this.state.hasUnsentMessages) {
statusBar = (
@@ -1378,7 +1254,9 @@ module.exports = React.createClass({
);
if (canSpeak) {
messageComposer =
-
+
}
// TODO: Why aren't we storing the term/scope/count in this format
@@ -1387,7 +1265,7 @@ module.exports = React.createClass({
searchInfo = {
searchTerm : this.state.searchTerm,
searchScope : this.state.searchScope,
- searchCount : this.state.searchCount,
+ searchCount : this.state.searchResults.count,
};
}
@@ -1433,6 +1311,33 @@ module.exports = React.createClass({
}
+
+ // 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 = (
+
+
+ {this.getSearchResultTiles()}
+
+ );
+ hideMessagePanel = true;
+ }
+
+ var messagePanel = (
+
+
+ {this.getEventTiles()}
+
+ );
+
return (
-
-
-
- -
-
- {this.getEventTiles()}
-
-
-
+ { messagePanel }
+ { searchResultsPanel }
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js
new file mode 100644
index 0000000000..042458717d
--- /dev/null
+++ b/src/components/structures/ScrollPanel.js
@@ -0,0 +1,376 @@
+/*
+Copyright 2015, 2016 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 q = require("q");
+
+var DEBUG_SCROLL = false;
+
+if (DEBUG_SCROLL) {
+ // using bind means that we get to keep useful line numbers in the console
+ var debuglog = console.log.bind(console);
+} else {
+ var debuglog = function () {};
+}
+
+/* This component implements an intelligent scrolling list.
+ *
+ * It wraps a list of
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.
+ *
+ * This should return a promise; no more calls will be made until the
+ * promise completes.
+ *
+ * The promise should resolve to true if there is more data to be
+ * retrieved in this direction (in which case onFillRequest may be
+ * called again immediately), or false if there is no more data in this
+ * directon (at this time) - which will stop the pagination cycle until
+ * the user scrolls again.
+ */
+ 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) { return q(false); },
+ onScroll: function() {},
+ };
+ },
+
+ componentWillMount: function() {
+ this._pendingFillRequests = {b: null, f: null};
+ this.resetScrollState();
+ },
+
+ componentDidMount: function() {
+ this.checkFillState();
+ },
+
+ 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();
+
+ // we also re-check the fill state, in case the paginate was inadequate
+ this.checkFillState();
+ },
+
+ onScroll: function(ev) {
+ var sn = this._getScrollNode();
+ debuglog("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();
+ debuglog("Saved scroll state", this.scrollState);
+
+ this.props.onScroll(ev);
+
+ this.checkFillState();
+ },
+
+ // 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;
+ },
+
+ // check the scroll state and send out backfill requests if necessary.
+ checkFillState: function() {
+ var sn = this._getScrollNode();
+
+ // if there is less than a screenful of messages above or below the
+ // viewport, try to get some more messages.
+ //
+ // scrollTop is the number of pixels between the top of the content and
+ // the top of the viewport.
+ //
+ // scrollHeight is the total height of the content.
+ //
+ // clientHeight is the height of the viewport (excluding borders,
+ // margins, and scrollbars).
+ //
+ //
+ // .---------. - -
+ // | | | scrollTop |
+ // .-+---------+-. - - |
+ // | | | | | |
+ // | | | | | clientHeight | scrollHeight
+ // | | | | | |
+ // `-+---------+-' - |
+ // | | |
+ // | | |
+ // `---------' -
+ //
+
+ if (sn.scrollTop < sn.clientHeight) {
+ // need to back-fill
+ this._maybeFill(true);
+ }
+ if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) {
+ // need to forward-fill
+ this._maybeFill(false);
+ }
+ },
+
+ // check if there is already a pending fill request. If not, set one off.
+ _maybeFill: function(backwards) {
+ var dir = backwards ? 'b' : 'f';
+ if (this._pendingFillRequests[dir]) {
+ debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
+ return;
+ }
+
+ debuglog("ScrollPanel: starting "+dir+" fill");
+
+ // onFillRequest can end up calling us recursively (via onScroll
+ // events) so make sure we set this before firing off the call. That
+ // does present the risk that we might not ever actually fire off the
+ // fill request, so wrap it in a try/catch.
+ this._pendingFillRequests[dir] = true;
+ var fillPromise;
+ try {
+ fillPromise = this.props.onFillRequest(backwards);
+ } catch (e) {
+ this._pendingFillRequests[dir] = false;
+ throw e;
+ }
+
+ q.finally(fillPromise, () => {
+ debuglog("ScrollPanel: "+dir+" fill complete");
+ this._pendingFillRequests[dir] = false;
+ }).then((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
+ // was insufficient.
+ this.checkFillState();
+ }
+ }).done();
+ },
+
+ // 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;
+ debuglog("Scrolled to top");
+ },
+
+ scrollToBottom: function() {
+ var scrollNode = this._getScrollNode();
+ scrollNode.scrollTop = scrollNode.scrollHeight;
+ debuglog("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;
+ }
+
+ debuglog("Scrolled to token", node.dataset.scrollToken, "+",
+ pixelOffset+":", scrollNode.scrollTop,
+ "(delta: "+scrollDelta+")");
+ 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();
+
+ 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 (
+
+
+ {this.props.children}
+
+
+
+ );
+ },
+});
diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js
index 717c7f57b2..12e502026f 100644
--- a/src/components/structures/UploadBar.js
+++ b/src/components/structures/UploadBar.js
@@ -1,5 +1,5 @@
/*
-Copyright 2015 OpenMarket Ltd
+Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js
index 59187bb69f..c1550f9b6b 100644
--- a/src/components/structures/UserSettings.js
+++ b/src/components/structures/UserSettings.js
@@ -1,5 +1,5 @@
/*
-Copyright 2015 OpenMarket Ltd
+Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -17,14 +17,22 @@ var React = require('react');
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var Modal = require('../../Modal');
+var dis = require("../../dispatcher");
var q = require('q');
var version = require('../../../package.json').version;
+var UserSettingsStore = require('../../UserSettingsStore');
module.exports = React.createClass({
displayName: 'UserSettings',
- Phases: {
- Loading: "loading",
- Display: "display",
+
+ propTypes: {
+ onClose: React.PropTypes.func
+ },
+
+ getDefaultProps: function() {
+ return {
+ onClose: function() {}
+ };
},
getInitialState: function() {
@@ -32,131 +40,227 @@ module.exports = React.createClass({
avatarUrl: null,
threePids: [],
clientVersion: version,
- phase: this.Phases.Loading,
+ phase: "UserSettings.LOADING", // LOADING, DISPLAY
};
},
componentWillMount: function() {
var self = this;
- var cli = MatrixClientPeg.get();
-
- var profile_d = cli.getProfileInfo(cli.credentials.userId);
- var threepid_d = cli.getThreePids();
-
- q.all([profile_d, threepid_d]).then(
- function(resps) {
- self.setState({
- avatarUrl: resps[0].avatar_url,
- threepids: resps[1].threepids,
- phase: self.Phases.Display,
- });
- },
- function(err) { console.err(err); }
- );
+ this._refreshFromServer();
},
- editAvatar: function() {
- var url = MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl);
- var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
- var avatarDialog = (
-
- );
- this.avatarDialog = Modal.createDialogWithElement(avatarDialog);
+ componentDidMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ this._me = MatrixClientPeg.get().credentials.userId;
},
- addEmail: function() {
-
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
},
- editDisplayName: function() {
- this.refs.displayname.edit();
+ _refreshFromServer: function() {
+ var self = this;
+ q.all([
+ UserSettingsStore.loadProfileInfo(), UserSettingsStore.loadThreePids()
+ ]).done(function(resps) {
+ self.setState({
+ avatarUrl: resps[0].avatar_url,
+ threepids: resps[1].threepids,
+ phase: "UserSettings.DISPLAY",
+ });
+ }, function(error) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Can't load user settings",
+ description: error.toString()
+ });
+ });
},
- changePassword: function() {
- var ChangePassword = sdk.getComponent('settings.ChangePassword');
- Modal.createDialog(ChangePassword);
+ onAction: function(payload) {
+ if (payload.action === "notifier_enabled") {
+ this.forceUpdate();
+ }
+ },
+
+ onAvatarSelected: function(ev) {
+ var self = this;
+ var changeAvatar = this.refs.changeAvatar;
+ if (!changeAvatar) {
+ console.error("No ChangeAvatar found to upload image to!");
+ return;
+ }
+ changeAvatar.onFileSelected(ev).done(function() {
+ // dunno if the avatar changed, re-check it.
+ self._refreshFromServer();
+ }, function(err) {
+ var errMsg = (typeof err === "string") ? err : (err.error || "");
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Error",
+ description: "Failed to set avatar. " + errMsg
+ });
+ });
},
onLogoutClicked: function(ev) {
var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt');
- this.logoutModal = Modal.createDialog(LogoutPrompt, {onCancel: this.onLogoutPromptCancel});
+ this.logoutModal = Modal.createDialog(
+ LogoutPrompt, {onCancel: this.onLogoutPromptCancel}
+ );
+ },
+
+ onPasswordChangeError: function(err) {
+ var errMsg = err.error || "";
+ if (err.httpStatus === 403) {
+ errMsg = "Failed to change password. Is your password correct?";
+ }
+ else if (err.httpStatus) {
+ errMsg += ` (HTTP status ${err.httpStatus})`;
+ }
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Error",
+ description: errMsg
+ });
+ },
+
+ onPasswordChanged: function() {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Success",
+ description: `Your password was successfully changed. You will not
+ receive push notifications on other devices until you
+ log back in to them.`
+ });
},
onLogoutPromptCancel: function() {
this.logoutModal.closeDialog();
},
- onAvatarDialogCancel: function() {
- this.avatarDialog.close();
+ onEnableNotificationsChange: function(event) {
+ UserSettingsStore.setEnableNotifications(event.target.checked);
},
render: function() {
- var Loader = sdk.getComponent("elements.Spinner");
- if (this.state.phase === this.Phases.Loading) {
- return
+ switch (this.state.phase) {
+ case "UserSettings.LOADING":
+ var Loader = sdk.getComponent("elements.Spinner");
+ return (
+
+ );
+ case "UserSettings.DISPLAY":
+ break; // quit the switch to return the common state
+ default:
+ throw new Error("Unknown state.phase => " + this.state.phase);
}
- else if (this.state.phase === this.Phases.Display) {
- var ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
- var EnableNotificationsButton = sdk.getComponent('settings.EnableNotificationsButton');
- return (
+ // can only get here if phase is UserSettings.DISPLAY
+ var RoomHeader = sdk.getComponent('rooms.RoomHeader');
+ var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName");
+ var ChangePassword = sdk.getComponent("views.settings.ChangePassword");
+ var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
+ var avatarUrl = (
+ this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
+ );
+
+ return (
-
-
User Settings
-
-
-
-
- Profile Photo
+
+
+
Profile
+
+