diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js
index 4af6e96c96..ead0eb6492 100644
--- a/src/components/structures/MatrixChat.js
+++ b/src/components/structures/MatrixChat.js
@@ -29,6 +29,7 @@ var Login = require("./login/Login");
var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration");
+var Modal = require("../../Modal");
var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix");
@@ -41,7 +42,8 @@ module.exports = React.createClass({
ConferenceHandler: React.PropTypes.any,
onNewScreen: React.PropTypes.func,
registrationUrl: React.PropTypes.string,
- enableGuest: React.PropTypes.bool
+ enableGuest: React.PropTypes.bool,
+ startingQueryParams: React.PropTypes.object
},
PageTypes: {
@@ -75,6 +77,12 @@ module.exports = React.createClass({
return s;
},
+ getDefaultProps: function() {
+ return {
+ startingQueryParams: {}
+ };
+ },
+
componentDidMount: function() {
this._autoRegisterAsGuest = false;
if (this.props.enableGuest) {
@@ -94,6 +102,9 @@ module.exports = React.createClass({
this.startMatrixClient();
}
this.focusComposer = false;
+ // scrollStateMap is a map from room id to the scroll state returned by
+ // RoomView.getScrollState()
+ this.scrollStateMap = {};
document.addEventListener("keydown", this.onKeyDown);
window.addEventListener("focus", this.onFocus);
@@ -246,28 +257,38 @@ module.exports = React.createClass({
});
break;
- case 'view_room':
- this.focusComposer = true;
- var newState = {
- currentRoom: payload.room_id,
- page_type: this.PageTypes.RoomView,
- };
- if (this.sdkReady) {
- // if the SDK is not ready yet, remember what room
- // we're supposed to be on but don't notify about
- // the new screen yet (we won't be showing it yet)
- // The normal case where this happens is navigating
- // to the room in the URL bar on page load.
- var presentedId = payload.room_id;
- var room = MatrixClientPeg.get().getRoom(payload.room_id);
- if (room) {
- var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
- if (theAlias) presentedId = theAlias;
+ case 'leave_room':
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+
+ var roomId = payload.room_id;
+ Modal.createDialog(QuestionDialog, {
+ title: "Leave room",
+ description: "Are you sure you want to leave the room?",
+ onFinished: function(should_leave) {
+ if (should_leave) {
+ var d = MatrixClientPeg.get().leave(roomId);
+
+ // FIXME: controller shouldn't be loading a view :(
+ var Loader = sdk.getComponent("elements.Spinner");
+ var modal = Modal.createDialog(Loader);
+
+ d.then(function() {
+ modal.close();
+ dis.dispatch({action: 'view_next_room'});
+ }, function(err) {
+ modal.close();
+ Modal.createDialog(ErrorDialog, {
+ title: "Failed to leave room",
+ description: err.toString()
+ });
+ });
+ }
}
- this.notifyNewScreen('room/'+presentedId);
- newState.ready = true;
- }
- this.setState(newState);
+ });
+ break;
+ case 'view_room':
+ this._viewRoom(payload.room_id);
break;
case 'view_prev_room':
roomIndexDelta = -1;
@@ -284,11 +305,7 @@ module.exports = React.createClass({
}
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1;
- this.focusComposer = true;
- this.setState({
- currentRoom: allRooms[roomIndex].roomId
- });
- this.notifyNewScreen('room/'+allRooms[roomIndex].roomId);
+ this._viewRoom(allRooms[roomIndex].roomId);
break;
case 'view_indexed_room':
var allRooms = RoomListSorter.mostRecentActivityFirst(
@@ -296,11 +313,7 @@ module.exports = React.createClass({
);
var roomIndex = payload.roomIndex;
if (allRooms[roomIndex]) {
- this.focusComposer = true;
- this.setState({
- currentRoom: allRooms[roomIndex].roomId
- });
- this.notifyNewScreen('room/'+allRooms[roomIndex].roomId);
+ this._viewRoom(allRooms[roomIndex].roomId);
}
break;
case 'view_room_alias':
@@ -324,21 +337,15 @@ module.exports = React.createClass({
});
break;
case 'view_user_settings':
- this.setState({
- page_type: this.PageTypes.UserSettings,
- });
+ this._setPage(this.PageTypes.UserSettings);
this.notifyNewScreen('settings');
break;
case 'view_create_room':
- this.setState({
- page_type: this.PageTypes.CreateRoom,
- });
+ this._setPage(this.PageTypes.CreateRoom);
this.notifyNewScreen('new');
break;
case 'view_room_directory':
- this.setState({
- page_type: this.PageTypes.RoomDirectory,
- });
+ this._setPage(this.PageTypes.RoomDirectory);
this.notifyNewScreen('directory');
break;
case 'notifier_enabled':
@@ -367,6 +374,58 @@ module.exports = React.createClass({
}
},
+ _setPage: function(pageType) {
+ // record the scroll state if we're in a room view.
+ this._updateScrollMap();
+
+ this.setState({
+ page_type: pageType,
+ });
+ },
+
+ _viewRoom: function(roomId) {
+ // before we switch room, record the scroll state of the current room
+ this._updateScrollMap();
+
+ this.focusComposer = true;
+ var newState = {
+ currentRoom: roomId,
+ page_type: this.PageTypes.RoomView,
+ };
+ if (this.sdkReady) {
+ // if the SDK is not ready yet, remember what room
+ // we're supposed to be on but don't notify about
+ // the new screen yet (we won't be showing it yet)
+ // The normal case where this happens is navigating
+ // to the room in the URL bar on page load.
+ var presentedId = roomId;
+ var room = MatrixClientPeg.get().getRoom(roomId);
+ if (room) {
+ var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
+ if (theAlias) presentedId = theAlias;
+ }
+ this.notifyNewScreen('room/'+presentedId);
+ newState.ready = true;
+ }
+ this.setState(newState);
+ if (this.scrollStateMap[roomId]) {
+ var scrollState = this.scrollStateMap[roomId];
+ this.refs.roomView.restoreScrollState(scrollState);
+ }
+ },
+
+ // update scrollStateMap according to the current scroll state of the
+ // room view.
+ _updateScrollMap: function() {
+ if (!this.refs.roomView) {
+ return;
+ }
+
+ var roomview = this.refs.roomView;
+ var state = roomview.getScrollState();
+ this.scrollStateMap[roomview.props.roomId] = state;
+ },
+
onLoggedIn: function(credentials) {
credentials.guest = Boolean(credentials.guest);
console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest);
@@ -385,7 +444,10 @@ module.exports = React.createClass({
startMatrixClient: function() {
var cli = MatrixClientPeg.get();
var self = this;
- cli.on('sync', function(state) {
+ cli.on('sync', function(state, prevState) {
+ if (state === "SYNCING" && prevState === "SYNCING") {
+ return;
+ }
console.log("MatrixClient sync state => %s", state);
if (state !== "PREPARED") { return; }
self.sdkReady = true;
@@ -434,7 +496,9 @@ module.exports = React.createClass({
Notifier.start();
UserActivity.start();
Presence.start();
- cli.startClient();
+ cli.startClient({
+ pendingEventOrdering: "end"
+ });
},
onKeyDown: function(ev) {
@@ -610,6 +674,22 @@ module.exports = React.createClass({
this.showScreen("settings");
},
+ 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');
@@ -634,6 +714,7 @@ module.exports = React.createClass({
case this.PageTypes.RoomView:
page_element = (
@@ -641,7 +722,7 @@ module.exports = React.createClass({
right_panel =
break;
case this.PageTypes.UserSettings:
- page_element =
+ page_element =
right_panel =
break;
case this.PageTypes.CreateRoom:
@@ -702,6 +783,7 @@ module.exports = React.createClass({
clientSecret={this.state.register_client_secret}
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
+ email={this.props.startingQueryParams.email}
hsUrl={this.props.config.default_hs_url}
isUrl={this.props.config.default_is_url}
registrationUrl={this.props.registrationUrl}
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 5b7496e8b2..56dd41d929 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -23,10 +23,8 @@ 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 filesize = require('filesize');
var Matrix = require("matrix-js-sdk");
var MatrixClientPeg = require("../../MatrixClientPeg");
@@ -35,18 +33,26 @@ 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");
var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
+var DEBUG_SCROLL = false;
+
module.exports = React.createClass({
displayName: 'RoomView',
propTypes: {
ConferenceHandler: React.PropTypes.any
},
+ /* properties in RoomView objects include:
+ *
+ * eventNodes: a map from event id to DOM node representing that event
+ */
getInitialState: function() {
var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null;
return {
@@ -59,7 +65,8 @@ module.exports = React.createClass({
searching: false,
searchResults: null,
syncState: MatrixClientPeg.get().getSyncState(),
- hasUnsentMessages: this._hasUnsentMessages(room)
+ hasUnsentMessages: this._hasUnsentMessages(room),
+ callState: null,
}
},
@@ -71,11 +78,26 @@ 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.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() {
if (this.refs.messagePanel) {
+ // disconnect the D&D event listeners from the message panel. This
+ // is really just for hygiene - the messagePanel is going to be
+ // deleted anyway, so it doesn't matter if the event listeners
+ // don't get cleaned up.
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
messagePanel.removeEventListener('drop', this.onDrop);
messagePanel.removeEventListener('dragover', this.onDragOver);
@@ -91,6 +113,8 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange);
}
+
+ window.removeEventListener('resize', this.onResize);
},
onAction: function(payload) {
@@ -107,22 +131,41 @@ module.exports = React.createClass({
this.forceUpdate();
break;
case 'notifier_enabled':
+ case 'upload_failed':
+ case 'upload_started':
+ case 'upload_finished':
this.forceUpdate();
break;
case 'call_state':
- if (CallHandler.getCallForRoom(this.props.roomId)) {
+ // don't filter out payloads for room IDs other than props.room because
+ // we may be interested in the conf 1:1 room
+
+ if (!payload.room_id) {
+ return;
+ }
+
+ var call = CallHandler.getCallForRoom(payload.room_id);
+ var callState;
+
+ if (call) {
// Call state has changed so we may be loading video elements
// which will obscure the message log.
// scroll to bottom
- var scrollNode = this._getScrollNode();
- if (scrollNode) {
- scrollNode.scrollTop = scrollNode.scrollHeight;
- }
+ this.scrollToBottom();
+ callState = call.call_state;
+ }
+ else {
+ callState = "ended";
}
// possibly remove the conf call notification if we're now in
// the conf
this._updateConfCallNotification();
+
+ this.setState({
+ callState: callState
+ });
+
break;
case 'user_activity':
this.sendReadReceipt();
@@ -130,18 +173,10 @@ module.exports = React.createClass({
}
},
- _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;
}
- },
-
- onSyncStateChange: function(state) {
this.setState({
syncState: state
});
@@ -167,19 +202,11 @@ module.exports = React.createClass({
if (this.state.joining) return;
if (room.roomId != this.props.roomId) return;
- var scrollNode = this._getScrollNode();
- if (scrollNode) {
- this.atBottom = (
- scrollNode.scrollHeight - scrollNode.scrollTop <=
- (scrollNode.clientHeight + 150) // 150?
- );
- }
-
var currentUnread = this.state.numUnreadMessages;
if (!toStartOfTimeline &&
(ev.getSender() !== MatrixClientPeg.get().credentials.userId)) {
// update unread count when scrolled up
- if (this.atBottom) {
+ if (!this.state.searchResults && this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) {
currentUnread = 0;
}
else {
@@ -187,15 +214,10 @@ module.exports = React.createClass({
}
}
-
this.setState({
room: MatrixClientPeg.get().getRoom(this.props.roomId),
numUnreadMessages: currentUnread
});
-
- if (toStartOfTimeline && !this.state.paginating) {
- this.fillSpace();
- }
},
onRoomName: function(room) {
@@ -217,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;
}
@@ -266,77 +293,113 @@ module.exports = React.createClass({
componentDidMount: function() {
if (this.refs.messagePanel) {
- var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
-
- messagePanel.addEventListener('drop', this.onDrop);
- messagePanel.addEventListener('dragover', this.onDragOver);
- messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd);
- messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd);
-
- var messageWrapperScroll = this._getScrollNode();
-
- messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight;
-
- this.sendReadReceipt();
-
- this.fillSpace();
+ this._initialiseMessagePanel();
}
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ var callState = call ? call.call_state : "ended";
+ this.setState({
+ callState: callState
+ });
+
this._updateConfCallNotification();
+
+ 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() {
+ var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
+ this.refs.messagePanel.initialised = true;
+
+ messagePanel.addEventListener('drop', this.onDrop);
+ messagePanel.addEventListener('dragover', this.onDragOver);
+ messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd);
+ messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd);
+
+ this.scrollToBottom();
+ this.sendReadReceipt();
+
+ this.refs.messagePanel.checkFillState();
},
componentDidUpdate: function() {
+ // we need to initialise the messagepanel if we've just joined the
+ // room. TODO: we really really ought to factor out messagepanel to a
+ // separate component to avoid this ridiculous dance.
if (!this.refs.messagePanel) return;
- var messageWrapperScroll = this._getScrollNode();
-
- if (this.state.paginating && !this.waiting_for_paginate) {
- var heightGained = messageWrapperScroll.scrollHeight - this.oldScrollHeight;
- messageWrapperScroll.scrollTop += heightGained;
- this.oldScrollHeight = undefined;
- if (!this.fillSpace()) {
- this.setState({paginating: false});
- }
- } else if (this.atBottom) {
- messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight;
- if (this.state.numUnreadMessages !== 0) {
- this.setState({numUnreadMessages: 0});
- }
+ if (!this.refs.messagePanel.initialised) {
+ this._initialiseMessagePanel();
}
},
- fillSpace: function() {
- if (!this.refs.messagePanel) return;
- if (this.state.searchResults) return; // TODO: paginate search results
- var messageWrapperScroll = this._getScrollNode();
- if (messageWrapperScroll.scrollTop < messageWrapperScroll.clientHeight && this.state.room.oldState.paginationToken) {
- this.setState({paginating: true});
+ _paginateCompleted: function() {
+ if (DEBUG_SCROLL) console.log("paginate complete");
- this.oldScrollHeight = messageWrapperScroll.scrollHeight;
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId)
+ });
- if (this.state.messageCap < this.state.room.timeline.length) {
- this.waiting_for_paginate = false;
- var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length);
- this.setState({messageCap: cap, paginating: true});
- } else {
- this.waiting_for_paginate = true;
- var cap = this.state.messageCap + PAGINATE_SIZE;
- this.setState({messageCap: cap, paginating: true});
- var self = this;
- MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(function() {
- self.waiting_for_paginate = false;
- if (self.isMounted()) {
- self.setState({
- room: MatrixClientPeg.get().getRoom(self.props.roomId)
- });
- }
- // wait and set paginating to false when the component updates
- });
- }
+ this.setState({paginating: false});
- return true;
+ // we might not have got enough (or, indeed, any) results from the
+ // pagination request, so give the messagePanel a chance to set off
+ // another.
+
+ if (this.refs.messagePanel) {
+ this.refs.messagePanel.checkFillState();
}
- return false;
+ },
+
+ 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");
+ }
+ },
+
+ // set off a pagination request.
+ onMessageListFillRequest: function(backwards) {
+ if (!backwards || this.state.paginating)
+ return;
+
+ // 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);
+ this.setState({messageCap: cap});
+ } else if(this.state.room.oldState.paginationToken) {
+ var cap = this.state.messageCap + PAGINATE_SIZE;
+ if (DEBUG_SCROLL) console.log("starting paginate to cap", cap);
+ this.setState({messageCap: cap, paginating: true});
+ MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done();
+ }
+ },
+
+ // 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() {
@@ -365,15 +428,10 @@ module.exports = React.createClass({
},
onMessageListScroll: function(ev) {
- if (this.refs.messagePanel) {
- var messageWrapperScroll = this._getScrollNode();
- var wasAtBottom = this.atBottom;
- this.atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1;
- if (this.atBottom && !wasAtBottom) {
- this.forceUpdate(); // remove unread msg count
- }
+ if (this.state.numUnreadMessages != 0 &&
+ this.refs.messagePanel.isAtBottom()) {
+ this.setState({numUnreadMessages: 0});
}
- if (!this.state.paginating) this.fillSpace();
},
onDragOver: function(ev) {
@@ -408,30 +466,10 @@ module.exports = React.createClass({
},
uploadFile: function(file) {
- this.setState({
- upload: {
- fileName: file.name,
- uploadedBytes: 0,
- totalBytes: file.size
- }
- });
var self = this;
ContentMessages.sendContentToRoom(
file, this.props.roomId, MatrixClientPeg.get()
- ).progress(function(ev) {
- //console.log("Upload: "+ev.loaded+" / "+ev.total);
- self.setState({
- upload: {
- fileName: file.name,
- uploadedBytes: ev.loaded,
- totalBytes: ev.total
- }
- });
- }).finally(function() {
- self.setState({
- upload: undefined
- });
- }).done(undefined, function(error) {
+ ).done(undefined, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failed to upload file",
@@ -445,7 +483,95 @@ module.exports = React.createClass({
},
onSearch: function(term, scope) {
+ this.setState({
+ searchTerm: term,
+ searchScope: scope,
+ searchResults: [],
+ searchHighlights: [],
+ searchCount: null,
+ searchCanPaginate: null,
+ });
+
+ // 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);
+ },
+
+ // fire off a request for a batch of search results
+ _getSearchBatch: function(term, scope) {
+ 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) {
+ 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.
+
+ // 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 ];
+ }
+
+ // append the new results to our existing results
+ var events = self.state.searchResults.concat(results.results);
+
+ self.setState({
+ searchHighlights: highlights,
+ searchResults: events,
+ searchCount: results.count,
+ searchCanPaginate: !!(results.next_batch),
+ });
+ self.nextSearchBatch = results.next_batch;
+ }, function(error) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Search failed",
+ description: error.toString()
+ });
+ }).finally(function() {
+ 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 :(
@@ -455,163 +581,155 @@ module.exports = React.createClass({
};
}
- var self = this;
- MatrixClientPeg.get().search({
- body: {
- search_categories: {
- room_events: {
- search_term: term,
- filter: filter,
- order_by: "recent",
- include_state: true,
- groupings: {
- group_by: [
- {
- key: "room_id"
- }
- ]
- },
- event_context: {
- before_limit: 1,
- after_limit: 1,
- include_profile: true,
- }
+ return {
+ search_categories: {
+ room_events: {
+ search_term: term,
+ filter: filter,
+ order_by: "recent",
+ event_context: {
+ before_limit: 1,
+ after_limit: 1,
+ include_profile: true,
}
}
- }
- }).then(function(data) {
- // for debugging:
- // data.search_categories.room_events.highlights = ["hello", "everybody"];
-
- var highlights;
- if (data.search_categories.room_events.highlights &&
- data.search_categories.room_events.highlights.length > 0)
- {
- // postgres on synapse returns us precise details of the
- // strings which actually got matched for highlighting.
- // for overlapping highlights, favour longer (more specific) terms first
- highlights = data.search_categories.room_events.highlights
- .sort(function(a, b) { b.length - a.length });
}
- else {
- // sqlite doesn't, so just try to highlight the literal search term
- highlights = [ term ];
+ }
+ },
+
+ 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(
+ No results
+
+ );
+ } else {
+ ret.push(
+ No more results
+
+ );
+ }
+ }
+
+ 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;
}
- self.setState({
- highlights: highlights,
- searchResults: data,
- searchScope: scope,
- });
- }, function(error) {
- var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
- Modal.createDialog(ErrorDialog, {
- title: "Search failed",
- description: error.toString()
- });
- });
+ var eventId = mxEv.getId();
+
+ if (this.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;
},
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 &&
- this.state.searchResults.search_categories.room_events.results &&
- this.state.searchResults.search_categories.room_events.groups)
- {
- // XXX: this dance is foul, due to the results API not directly returning sorted results
- var results = this.state.searchResults.search_categories.room_events.results;
- var roomIdGroups = this.state.searchResults.search_categories.room_events.groups.room_id;
- Object.keys(roomIdGroups)
- .sort(function(a, b) { roomIdGroups[a].order - roomIdGroups[b].order }) // WHY NOT RETURN AN ORDERED ARRAY?!?!?!
- .forEach(function(roomId)
- {
- // XXX: todo: merge overlapping results somehow?
- // XXX: why doesn't searching on name work?
- if (self.state.searchScope === 'All') {
- ret.push(Room: { cli.getRoom(roomId).name }
);
- }
-
- var resultList = roomIdGroups[roomId].results.map(function(eventId) { return results[eventId]; });
- for (var i = resultList.length - 1; i >= 0; i--) {
- var ts1 = resultList[i].result.origin_server_ts;
- ret.push(); // Rank: {resultList[i].rank}
- var mxEv = new Matrix.MatrixEvent(resultList[i].result);
- if (resultList[i].context.events_before[0]) {
- var mxEv2 = new Matrix.MatrixEvent(resultList[i].context.events_before[0]);
- if (EventTile.haveTileForEvent(mxEv2)) {
- ret.push();
- }
- }
- if (EventTile.haveTileForEvent(mxEv)) {
- ret.push();
- }
- if (resultList[i].context.events_after[0]) {
- var mxEv2 = new Matrix.MatrixEvent(resultList[i].context.events_after[0]);
- if (EventTile.haveTileForEvent(mxEv2)) {
- ret.push();
- }
- }
- }
- });
- 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)) {
continue;
}
-
- var continuation = false;
- var last = false;
- var dateSeparator = null;
- if (i == this.state.room.timeline.length - 1) {
- last = true;
+ if (this.props.ConferenceHandler && mxEv.getType() === "m.room.member") {
+ if (this.props.ConferenceHandler.isConferenceUser(mxEv.getSender()) ||
+ this.props.ConferenceHandler.isConferenceUser(mxEv.getStateKey())) {
+ continue; // suppress conf user join/parts
+ }
}
- 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())
+
+ // is this a continuation of the previous message?
+ var continuation = false;
+ 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 = ;
- 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 = ;
+ // 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 = ;
+ ret.push(dateSeparator);
continuation = false;
}
- ret.unshift(
-
- );
- if (dateSeparator) {
- ret.unshift(dateSeparator);
+ var last = false;
+ if (i == this.state.room.timeline.length - 1) {
+ // XXX: we might not show a tile for the last event.
+ last = true;
}
- ++count;
+
+ var eventId = mxEv.getId();
+ ret.push(
+
+
+
+ );
+
+ prevEvent = mxEv;
}
+
return ret;
},
@@ -781,7 +899,27 @@ module.exports = React.createClass({
},
onCancelClick: function() {
- this.setState(this.getInitialState());
+ this.setState({editingRoomSettings: false});
+ },
+
+ onLeaveClick: function() {
+ dis.dispatch({
+ action: 'leave_room',
+ room_id: this.props.roomId,
+ });
+ },
+
+ onForgetClick: function() {
+ MatrixClientPeg.get().forget(this.props.roomId).done(function() {
+ dis.dispatch({ action: 'view_next_room' });
+ }, function(err) {
+ var errCode = err.errcode || "unknown error code";
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Error",
+ description: `Failed to forget room (${errCode})`
+ });
+ });
},
onRejectButtonClicked: function(ev) {
@@ -807,6 +945,13 @@ module.exports = React.createClass({
this.setState({ searching: true });
},
+ onCancelSearchClick: function () {
+ this.setState({
+ searching: false,
+ searchResults: null,
+ });
+ },
+
onConferenceNotificationClick: function() {
dis.dispatch({
action: 'place_call',
@@ -823,9 +968,129 @@ module.exports = React.createClass({
},
scrollToBottom: function() {
- var scrollNode = this._getScrollNode();
- if (!scrollNode) return;
- scrollNode.scrollTop = scrollNode.scrollHeight;
+ var messagePanel = this.refs.messagePanel;
+ if (!messagePanel) return;
+ messagePanel.scrollToBottom();
+ },
+
+ // scroll the event view to put the given event at the bottom.
+ //
+ // pixel_offset gives the number of pixels between the bottom of the event
+ // and the bottom of the container.
+ scrollToEvent: function(eventId, pixelOffset) {
+ var messagePanel = this.refs.messagePanel;
+ if (!messagePanel) return;
+
+ var idx = this._indexForEventId(eventId);
+ if (idx === null) {
+ // we don't seem to have this event in our timeline. Presumably
+ // it's fallen out of scrollback. We ought to backfill until we
+ // find it, but we'd have to be careful we didn't backfill forever
+ // looking for a non-existent event.
+ //
+ // for now, just scroll to the top of the buffer.
+ console.log("Refusing to scroll to unknown event "+eventId);
+ messagePanel.scrollToTop();
+ return;
+ }
+
+ // we might need to roll back the messagecap (to generate tiles for
+ // older messages). This just means telling getEventTiles to create
+ // tiles for events we already have in our timeline (we already know
+ // the event in question is in our timeline, so we shouldn't need to
+ // backfill).
+ //
+ // we actually wind back slightly further than the event in question,
+ // because we want the event to be at the *bottom* of the container.
+ // Don't roll it back past the timeline we have, though.
+ var minCap = this.state.room.timeline.length - Math.min(idx - INITIAL_SIZE, 0);
+ if (minCap > this.state.messageCap) {
+ this.setState({messageCap: minCap});
+ }
+
+ // the scrollTokens on our DOM nodes are the event IDs, so we can pass
+ // eventId directly into _scrollToToken.
+ messagePanel.scrollToToken(eventId, pixelOffset);
+ },
+
+ // get the current scroll position of the room, so that it can be
+ // restored when we switch back to it
+ getScrollState: function() {
+ var messagePanel = this.refs.messagePanel;
+ if (!messagePanel) return null;
+
+ return messagePanel.getScrollState();
+ },
+
+ restoreScrollState: function(scrollState) {
+ var messagePanel = this.refs.messagePanel;
+ if (!messagePanel) return null;
+
+ if(scrollState.atBottom) {
+ // we were at the bottom before. Ideally we'd scroll to the
+ // 'read-up-to' mark here.
+ messagePanel.scrollToBottom();
+
+ } else if (scrollState.lastDisplayedScrollToken) {
+ // we might need to backfill, so we call scrollToEvent rather than
+ // scrollToToken here. The scrollTokens on our DOM nodes are the
+ // event IDs, so lastDisplayedScrollToken will be the event ID we need,
+ // and we can pass it directly into scrollToEvent.
+ this.scrollToEvent(scrollState.lastDisplayedScrollToken,
+ scrollState.pixelOffset);
+ }
+ },
+
+ onResize: function(e) {
+ // It seems flexbox doesn't give us a way to constrain the auxPanel height to have
+ // a minimum of the height of the video element, whilst also capping it from pushing out the page
+ // so we have to do it via JS instead. In this implementation we cap the height by putting
+ // a maxHeight on the underlying remote video tag.
+ var auxPanelMaxHeight;
+ if (this.refs.callView) {
+ // XXX: don't understand why we have to call findDOMNode here in react 0.14 - it should already be a DOM node.
+ var video = ReactDOM.findDOMNode(this.refs.callView.refs.video.refs.remote);
+
+ // header + footer + status + give us at least 100px of scrollback at all times.
+ auxPanelMaxHeight = window.innerHeight - (83 + 72 + 36 + 100);
+
+ // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
+ // but it's better than the video going missing entirely
+ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
+
+ video.style.maxHeight = auxPanelMaxHeight + "px";
+ }
+ },
+
+ onFullscreenClick: function() {
+ dis.dispatch({
+ action: 'video_fullscreen',
+ fullscreen: true
+ }, true);
+ },
+
+ onMuteAudioClick: function() {
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ if (!call) {
+ return;
+ }
+ var newState = !call.isMicrophoneMuted();
+ call.setMicrophoneMuted(newState);
+ this.setState({
+ audioMuted: newState
+ });
+ },
+
+ onMuteVideoClick: function() {
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ if (!call) {
+ return;
+ }
+ var newState = !call.isLocalVideoMuted();
+ call.setLocalVideoMuted(newState);
+ this.setState({
+ videoMuted: newState
+ });
},
render: function() {
@@ -834,6 +1099,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) {
@@ -850,7 +1116,8 @@ module.exports = React.createClass({
}
var myUserId = MatrixClientPeg.get().credentials.userId;
- if (this.state.room.currentState.members[myUserId].membership == 'invite') {
+ 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 (
@@ -859,7 +1126,8 @@ module.exports = React.createClass({
);
} else {
- var inviteEvent = this.state.room.currentState.members[myUserId].events.member.event;
+ var inviteEvent = myMember.events.member;
+ var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
// XXX: Leaving this intentionally basic for now because invites are about to change totally
var joinErrorText = this.state.joinError ? "Failed to join room!" : "";
var rejectErrorText = this.state.rejectError ? "Failed to reject invite!" : "";
@@ -867,7 +1135,7 @@ module.exports = React.createClass({
-
{inviteEvent.user_id} has invited you to a room
+
{inviterName} has invited you to a room
@@ -883,9 +1151,7 @@ module.exports = React.createClass({
loading: this.state.paginating
});
- var statusBar = (
-
- );
+ var statusBar;
// for testing UI...
// this.state.upload = {
@@ -894,31 +1160,12 @@ module.exports = React.createClass({
// fileName: "testing_fooble.jpg",
// }
- if (this.state.upload) {
- var innerProgressStyle = {
- width: ((this.state.upload.uploadedBytes / this.state.upload.totalBytes) * 100) + '%'
- };
- var uploadedSize = filesize(this.state.upload.uploadedBytes);
- var totalSize = filesize(this.state.upload.totalBytes);
- if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) {
- uploadedSize = uploadedSize.replace(/ .*/, '');
- }
- statusBar = (
-
-
-

-

-
- { uploadedSize } / { totalSize }
-
-
Uploading {this.state.upload.fileName}
-
- );
- } else {
+ if (ContentMessages.getCurrentUploads().length > 0) {
+ var UploadBar = sdk.getComponent('structures.UploadBar');
+ statusBar =
+ } else if (!this.state.searchResults) {
var typingString = this.getWhoIsTypingString();
- //typingString = "Testing typing...";
+ // typingString = "S͚͍̭̪̤͙̱͙̖̥͙̥̤̻̙͕͓͂̌ͬ͐̂k̜̝͎̰̥̻̼̂̌͛͗͊̅̒͂̊̍̍͌̈̈́͌̋̊ͬa͉̯͚̺̗̳̩ͪ̋̑͌̓̆̍̂̉̏̅̆ͧ̌̑v̲̲̪̝ͥ̌ͨͮͭ̊͆̾ͮ̍ͮ͑̚e̮̙͈̱̘͕̼̮͒ͩͨͫ̃͗̇ͩ͒ͣͦ͒̄̍͐ͣ̿ͥṘ̗̺͇̺̺͔̄́̊̓͊̍̃ͨ̚ā̼͎̘̟̼͎̜̪̪͚̋ͨͨͧ̓ͦͯͤ̄͆̋͂ͩ͌ͧͅt̙̙̹̗̦͖̞ͫͪ͑̑̅ͪ̃̚ͅ is typing...";
var unreadMsgs = this.getUnreadMessagesString();
// no conn bar trumps unread count since you can't get unread messages
// without a connection! (technically may already have some but meh)
@@ -927,7 +1174,7 @@ module.exports = React.createClass({
if (this.state.syncState === "ERROR") {
statusBar = (
-

+
Connectivity to the server has been lost.
@@ -939,10 +1186,25 @@ module.exports = React.createClass({
);
}
+ else if (this.tabComplete.isTabCompleting()) {
+ var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
+ statusBar = (
+
+
...
+
+
+
+

+ Auto-complete
+
+
+
+ );
+ }
else if (this.state.hasUnsentMessages) {
statusBar = (
-

+
Some of your messages have not been sent.
@@ -971,7 +1233,7 @@ module.exports = React.createClass({
statusBar = (
...
- {typingString}
+
{typingString}
);
}
@@ -986,7 +1248,7 @@ module.exports = React.createClass({
aux =
;
}
else if (this.state.searching) {
- aux =
;
+ aux = ;
}
var conferenceCallNotification = null;
@@ -1006,41 +1268,129 @@ module.exports = React.createClass({
if (this.state.draggingFile) {
fileDropTarget =
-

+

Drop File Here
;
}
- var messageComposer;
- if (!this.state.searchResults) {
+ var messageComposer, searchInfo;
+ var canSpeak = (
+ // joined and not showing search results
+ myMember && (myMember.membership == 'join') && !this.state.searchResults
+ );
+ if (canSpeak) {
messageComposer =
-
+
}
+ // TODO: Why aren't we storing the term/scope/count in this format
+ // in this.state if this is what RoomHeader desires?
+ if (this.state.searchResults) {
+ searchInfo = {
+ searchTerm : this.state.searchTerm,
+ searchScope : this.state.searchScope,
+ searchCount : this.state.searchCount,
+ };
+ }
+
+ var call = CallHandler.getCallForRoom(this.props.roomId);
+ //var call = CallHandler.getAnyActiveCall();
+ var inCall = false;
+ if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) {
+ inCall = true;
+ var zoomButton, voiceMuteButton, videoMuteButton;
+
+ if (call.type === "video") {
+ zoomButton = (
+
+

+
+ );
+
+ videoMuteButton =
+
+
)
+
+ }
+ voiceMuteButton =
+
+
)
+
+
+ if (!statusBar) {
+ statusBar =
+
+

+
Active call
+
;
+ }
+
+ statusBar =
+
+ { voiceMuteButton }
+ { videoMuteButton }
+ { zoomButton }
+ { statusBar }
+

+
+ }
+
+
+ // 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 (
-
-
+
+
+ { fileDropTarget }
-
+
{ conferenceCallNotification }
{ aux }
-
-
- { fileDropTarget }
-
- -
-
- {this.getEventTiles()}
-
-
-
+ { messagePanel }
+ { searchResultsPanel }
- { this.state.searchResults ? null : statusBar }
+ { statusBar }
{ messageComposer }
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js
new file mode 100644
index 0000000000..4a45bf5a39
--- /dev/null
+++ b/src/components/structures/ScrollPanel.js
@@ -0,0 +1,288 @@
+/*
+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
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();
+ },
+
+ // 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 (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 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
new file mode 100644
index 0000000000..717c7f57b2
--- /dev/null
+++ b/src/components/structures/UploadBar.js
@@ -0,0 +1,93 @@
+/*
+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 ContentMessages = require('../../ContentMessages');
+var dis = require('../../dispatcher');
+var filesize = require('filesize');
+
+module.exports = React.createClass({displayName: 'UploadBar',
+ propTypes: {
+ room: React.PropTypes.object
+ },
+
+ componentDidMount: function() {
+ dis.register(this.onAction);
+ this.mounted = true;
+ },
+
+ componentWillUnmount: function() {
+ this.mounted = false;
+ },
+
+ onAction: function(payload) {
+ switch (payload.action) {
+ case 'upload_progress':
+ case 'upload_finished':
+ case 'upload_failed':
+ if (this.mounted) this.forceUpdate();
+ break;
+ }
+ },
+
+ render: function() {
+ var uploads = ContentMessages.getCurrentUploads();
+ if (uploads.length == 0) {
+ return
+ }
+
+ var upload;
+ for (var i = 0; i < uploads.length; ++i) {
+ if (uploads[i].roomId == this.props.room.roomId) {
+ upload = uploads[i];
+ break;
+ }
+ }
+ if (!upload) {
+ upload = uploads[0];
+ }
+
+ var innerProgressStyle = {
+ width: ((upload.loaded / (upload.total || 1)) * 100) + '%'
+ };
+ var uploadedSize = filesize(upload.loaded);
+ var totalSize = filesize(upload.total);
+ if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) {
+ uploadedSize = uploadedSize.replace(/ .*/, '');
+ }
+
+ var others;
+ if (uploads.length > 1) {
+ others = 'and '+(uploads.length - 1) + ' other' + (uploads.length > 2 ? 's' : '');
+ }
+
+ return (
+
+
+

+

+
+ { uploadedSize } / { totalSize }
+
+
Uploading {upload.fileName}{others}
+
+ );
+ }
+});
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js
index 59187bb69f..c8916bfd44 100644
--- a/src/components/structures/UserSettings.js
+++ b/src/components/structures/UserSettings.js
@@ -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
+
+
+
+
+
+
-
-
+ {this.state.threepids.map(function(val, pidIndex) {
+ var id = "email-" + val.address;
+ return (
+
+ );
+ })}
+
-
- {this.state.threepids.map(function(val) {
- return
{val.address}
;
- })}
-
-
-
-
));
}
diff --git a/src/components/views/rooms/TabCompleteBar.js b/src/components/views/rooms/TabCompleteBar.js
new file mode 100644
index 0000000000..9668c4b3ac
--- /dev/null
+++ b/src/components/views/rooms/TabCompleteBar.js
@@ -0,0 +1,46 @@
+/*
+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.
+*/
+
+'use strict';
+
+var React = require('react');
+var MatrixClientPeg = require("../../../MatrixClientPeg");
+
+module.exports = React.createClass({
+ displayName: 'TabCompleteBar',
+
+ propTypes: {
+ entries: React.PropTypes.array.isRequired
+ },
+
+ render: function() {
+ return (
+
+ {this.props.entries.map(function(entry, i) {
+ return (
+
+ {entry.getImageJsx()}
+
+ {entry.getText()}
+
+
+ );
+ })}
+
+ );
+ }
+});
diff --git a/src/components/views/settings/ChangeAvatar.js b/src/components/views/settings/ChangeAvatar.js
index 2ae50a0cae..ee2b5ad5e1 100644
--- a/src/components/views/settings/ChangeAvatar.js
+++ b/src/components/views/settings/ChangeAvatar.js
@@ -23,6 +23,9 @@ module.exports = React.createClass({
propTypes: {
initialAvatarUrl: React.PropTypes.string,
room: React.PropTypes.object,
+ // if false, you need to call changeAvatar.onFileSelected yourself.
+ showUploadSection: React.PropTypes.bool,
+ className: React.PropTypes.string
},
Phases: {
@@ -31,6 +34,13 @@ module.exports = React.createClass({
Error: "error",
},
+ getDefaultProps: function() {
+ return {
+ showUploadSection: true,
+ className: "mx_Dialog_content" // FIXME - shouldn't be this by default
+ };
+ },
+
getInitialState: function() {
return {
avatarUrl: this.props.initialAvatarUrl,
@@ -55,7 +65,7 @@ module.exports = React.createClass({
phase: this.Phases.Uploading
});
var self = this;
- MatrixClientPeg.get().uploadContent(file).then(function(url) {
+ var httpPromise = MatrixClientPeg.get().uploadContent(file).then(function(url) {
newUrl = url;
if (self.props.room) {
return MatrixClientPeg.get().sendStateEvent(
@@ -67,7 +77,9 @@ module.exports = React.createClass({
} else {
return MatrixClientPeg.get().setAvatarUrl(url);
}
- }).done(function() {
+ });
+
+ httpPromise.done(function() {
self.setState({
phase: self.Phases.Display,
avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl)
@@ -78,11 +90,13 @@ module.exports = React.createClass({
});
self.onError(error);
});
+
+ return httpPromise;
},
onFileSelected: function(ev) {
this.avatarSet = true;
- this.setAvatarFromFile(ev.target.files[0]);
+ return this.setAvatarFromFile(ev.target.files[0]);
},
onError: function(error) {
@@ -106,19 +120,26 @@ module.exports = React.createClass({
avatarImg =

;
}
+ var uploadSection;
+ if (this.props.showUploadSection) {
+ uploadSection = (
+
+ Upload new:
+
+ {this.state.errorText}
+
+ );
+ }
+
switch (this.state.phase) {
case this.Phases.Display:
case this.Phases.Error:
return (
-
+
{avatarImg}
-
- Upload new:
-
- {this.state.errorText}
-
+ {uploadSection}
);
case this.Phases.Uploading:
diff --git a/src/components/views/settings/ChangeDisplayName.js b/src/components/views/settings/ChangeDisplayName.js
index 4af413cfbe..9410b02290 100644
--- a/src/components/views/settings/ChangeDisplayName.js
+++ b/src/components/views/settings/ChangeDisplayName.js
@@ -98,7 +98,9 @@ module.exports = React.createClass({
} else {
var EditableText = sdk.getComponent('elements.EditableText');
return (
-
+
);
}
}
diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js
index a6666b7ed1..219ad10714 100644
--- a/src/components/views/settings/ChangePassword.js
+++ b/src/components/views/settings/ChangePassword.js
@@ -18,30 +18,47 @@ limitations under the License.
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
+var sdk = require("../../../index");
module.exports = React.createClass({
displayName: 'ChangePassword',
propTypes: {
onFinished: React.PropTypes.func,
+ onError: React.PropTypes.func,
+ onCheckPassword: React.PropTypes.func,
+ rowClassName: React.PropTypes.string,
+ rowLabelClassName: React.PropTypes.string,
+ rowInputClassName: React.PropTypes.string,
+ buttonClassName: React.PropTypes.string
},
Phases: {
Edit: "edit",
Uploading: "uploading",
- Error: "error",
- Success: "Success"
+ Error: "error"
},
getDefaultProps: function() {
return {
onFinished: function() {},
+ onError: function() {},
+ onCheckPassword: function(oldPass, newPass, confirmPass) {
+ if (newPass !== confirmPass) {
+ return {
+ error: "New passwords don't match."
+ };
+ } else if (!newPass || newPass.length === 0) {
+ return {
+ error: "Passwords can't be empty"
+ };
+ }
+ }
};
},
getInitialState: function() {
return {
- phase: this.Phases.Edit,
- errorString: ''
+ phase: this.Phases.Edit
}
},
@@ -55,60 +72,72 @@ module.exports = React.createClass({
};
this.setState({
- phase: this.Phases.Uploading,
- errorString: '',
- })
-
- var d = cli.setPassword(authDict, new_password);
+ phase: this.Phases.Uploading
+ });
var self = this;
- d.then(function() {
- self.setState({
- phase: self.Phases.Success,
- errorString: '',
- })
+ cli.setPassword(authDict, new_password).then(function() {
+ self.props.onFinished();
}, function(err) {
+ self.props.onError(err);
+ }).finally(function() {
self.setState({
- phase: self.Phases.Error,
- errorString: err.toString()
- })
- });
+ phase: self.Phases.Edit
+ });
+ }).done();
},
onClickChange: function() {
var old_password = this.refs.old_input.value;
var new_password = this.refs.new_input.value;
var confirm_password = this.refs.confirm_input.value;
- if (new_password != confirm_password) {
- this.setState({
- state: this.Phases.Error,
- errorString: "Passwords don't match"
- });
- } else if (new_password == '' || old_password == '') {
- this.setState({
- state: this.Phases.Error,
- errorString: "Passwords can't be empty"
- });
- } else {
+ var err = this.props.onCheckPassword(
+ old_password, new_password, confirm_password
+ );
+ if (err) {
+ this.props.onError(err);
+ }
+ else {
this.changePassword(old_password, new_password);
}
},
render: function() {
+ var rowClassName = this.props.rowClassName;
+ var rowLabelClassName = this.props.rowLabelClassName;
+ var rowInputClassName = this.props.rowInputClassName
+ var buttonClassName = this.props.buttonClassName;
+
switch (this.state.phase) {
case this.Phases.Edit:
- case this.Phases.Error:
return (
-
-
-
{this.state.errorString}
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Change Password
);
@@ -119,17 +148,6 @@ module.exports = React.createClass({
);
- case this.Phases.Success:
- return (
-
-
- Success!
-
-
-
-
-
- )
}
}
});
diff --git a/src/components/views/voip/CallView.js b/src/components/views/voip/CallView.js
index fbaed1dcd7..d67147dd1e 100644
--- a/src/components/views/voip/CallView.js
+++ b/src/components/views/voip/CallView.js
@@ -35,19 +35,13 @@ module.exports = React.createClass({
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
- this._trackedRoom = null;
if (this.props.room) {
- this._trackedRoom = this.props.room;
- this.showCall(this._trackedRoom.roomId);
+ this.showCall(this.props.room.roomId);
}
else {
+ // XXX: why would we ever not have a this.props.room?
var call = CallHandler.getAnyActiveCall();
if (call) {
- console.log(
- "Global CallView is now tracking active call in room %s",
- call.roomId
- );
- this._trackedRoom = MatrixClientPeg.get().getRoom(call.roomId);
this.showCall(call.roomId);
}
}
@@ -81,7 +75,7 @@ module.exports = React.createClass({
// and for the voice stream of screen captures
call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
}
- if (call && call.type === "video" && call.state !== 'ended') {
+ if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") {
// if this call is a conf call, don't display local video as the
// conference will have us in it
this.getVideoView().getLocalVideoElement().style.display = (
diff --git a/src/components/views/voip/IncomingCallBox.js b/src/components/views/voip/IncomingCallBox.js
index 263bbf543c..a9601931bb 100644
--- a/src/components/views/voip/IncomingCallBox.js
+++ b/src/components/views/voip/IncomingCallBox.js
@@ -21,87 +21,29 @@ var CallHandler = require("../../../CallHandler");
module.exports = React.createClass({
displayName: 'IncomingCallBox',
- componentDidMount: function() {
- this.dispatcherRef = dis.register(this.onAction);
- },
-
- componentWillUnmount: function() {
- dis.unregister(this.dispatcherRef);
- },
-
- getInitialState: function() {
- return {
- incomingCall: null
- }
- },
-
- onAction: function(payload) {
- if (payload.action !== 'call_state') {
- return;
- }
- var call = CallHandler.getCall(payload.room_id);
- if (!call || call.call_state !== 'ringing') {
- this.setState({
- incomingCall: null,
- });
- this.getRingAudio().pause();
- return;
- }
- if (call.call_state === "ringing") {
- this.getRingAudio().load();
- this.getRingAudio().play();
- }
- else {
- this.getRingAudio().pause();
- }
-
- this.setState({
- incomingCall: call
- });
- },
-
onAnswerClick: function() {
dis.dispatch({
action: 'answer',
- room_id: this.state.incomingCall.roomId
+ room_id: this.props.incomingCall.roomId
});
},
onRejectClick: function() {
dis.dispatch({
action: 'hangup',
- room_id: this.state.incomingCall.roomId
+ room_id: this.props.incomingCall.roomId
});
},
- getRingAudio: function() {
- return this.refs.ringAudio;
- },
-
render: function() {
- // NB: This block MUST have a "key" so React doesn't clobber the elements
- // between in-call / not-in-call.
- var audioBlock = (
-
- );
- if (!this.state.incomingCall || !this.state.incomingCall.roomId) {
- return (
-
- {audioBlock}
-
- );
- }
- var caller = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId).name;
+ var room = this.props.incomingCall ? MatrixClientPeg.get().getRoom(this.props.incomingCall.roomId) : null;
+ var caller = room ? room.name : "unknown";
return (
-
- {audioBlock}
+
- Incoming { this.state.incomingCall ? this.state.incomingCall.type : '' } call from { caller }
+ Incoming { this.props.incomingCall ? this.props.incomingCall.type : '' } call from { caller }