+ );
+ } else if (this.state.screen == 'register') {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
}
-};
+});
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
new file mode 100644
index 0000000000..9fe952dd66
--- /dev/null
+++ b/src/components/structures/RoomView.js
@@ -0,0 +1,1051 @@
+/*
+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.
+*/
+
+// TODO: This component is enormous! There's several things which could stand-alone:
+// - Aux component
+// - Search results component
+// - Drag and drop
+// - File uploading - uploadFile()
+// - Timeline component (alllll the logic in getEventTiles())
+
+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");
+var ContentMessages = require("../../ContentMessages");
+var WhoIsTyping = require("../../WhoIsTyping");
+var Modal = require("../../Modal");
+var sdk = require('../../index');
+var CallHandler = require('../../CallHandler');
+var Resend = require("../../Resend");
+var dis = require("../../dispatcher");
+
+var PAGINATE_SIZE = 20;
+var INITIAL_SIZE = 20;
+
+module.exports = React.createClass({
+ displayName: 'RoomView',
+ propTypes: {
+ ConferenceHandler: React.PropTypes.any // VectorConferenceHandler
+ },
+
+ getInitialState: function() {
+ var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null;
+ return {
+ room: room,
+ messageCap: INITIAL_SIZE,
+ editingRoomSettings: false,
+ uploadingRoomSettings: false,
+ numUnreadMessages: 0,
+ draggingFile: false,
+ searching: false,
+ searchResults: null,
+ syncState: MatrixClientPeg.get().getSyncState(),
+ hasUnsentMessages: this._hasUnsentMessages(room)
+ }
+ },
+
+ componentWillMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
+ MatrixClientPeg.get().on("Room.name", this.onRoomName);
+ MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
+ MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
+ MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
+ MatrixClientPeg.get().on("sync", this.onSyncStateChange);
+ this.atBottom = true;
+ },
+
+ componentWillUnmount: function() {
+ if (this.refs.messagePanel) {
+ var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
+ messagePanel.removeEventListener('drop', this.onDrop);
+ messagePanel.removeEventListener('dragover', this.onDragOver);
+ messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd);
+ messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd);
+ }
+ dis.unregister(this.dispatcherRef);
+ if (MatrixClientPeg.get()) {
+ MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
+ MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
+ MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
+ MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
+ MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
+ MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange);
+ }
+ },
+
+ onAction: function(payload) {
+ switch (payload.action) {
+ case 'message_send_failed':
+ case 'message_sent':
+ this.setState({
+ hasUnsentMessages: this._hasUnsentMessages(this.state.room)
+ });
+ case 'message_resend_started':
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId)
+ });
+ this.forceUpdate();
+ break;
+ case 'notifier_enabled':
+ this.forceUpdate();
+ break;
+ case 'call_state':
+ if (CallHandler.getCallForRoom(this.props.roomId)) {
+ // 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;
+ }
+ }
+
+ // possibly remove the conf call notification if we're now in
+ // the conf
+ this._updateConfCallNotification();
+ break;
+ case 'user_activity':
+ this.sendReadReceipt();
+ break;
+ }
+ },
+
+ _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) {
+ this.setState({
+ syncState: state
+ });
+ },
+
+ // MatrixRoom still showing the messages from the old room?
+ // Set the key to the room_id. Sadly you can no longer get at
+ // the key from inside the component, or we'd check this in code.
+ /*componentWillReceiveProps: function(props) {
+ },*/
+
+ onRoomTimeline: function(ev, room, toStartOfTimeline) {
+ if (!this.isMounted()) return;
+
+ // ignore anything that comes in whilst paginating: we get one
+ // event for each new matrix event so this would cause a huge
+ // number of UI updates. Just update the UI when the paginate
+ // call returns.
+ if (this.state.paginating) return;
+
+ // no point handling anything while we're waiting for the join to finish:
+ // we'll only be showing a spinner.
+ 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) {
+ currentUnread = 0;
+ }
+ else {
+ currentUnread += 1;
+ }
+ }
+
+
+ this.setState({
+ room: MatrixClientPeg.get().getRoom(this.props.roomId),
+ numUnreadMessages: currentUnread
+ });
+
+ if (toStartOfTimeline && !this.state.paginating) {
+ this.fillSpace();
+ }
+ },
+
+ onRoomName: function(room) {
+ if (room.roomId == this.props.roomId) {
+ this.setState({
+ room: room
+ });
+ }
+ },
+
+ onRoomReceipt: function(receiptEvent, room) {
+ if (room.roomId == this.props.roomId) {
+ this.forceUpdate();
+ }
+ },
+
+ onRoomMemberTyping: function(ev, member) {
+ this.forceUpdate();
+ },
+
+ onRoomStateMember: function(ev, state, member) {
+ if (!this.props.ConferenceHandler) {
+ return;
+ }
+ if (member.roomId !== this.props.roomId ||
+ member.userId !== this.props.ConferenceHandler.getConferenceUserIdForRoom(member.roomId)) {
+ return;
+ }
+ this._updateConfCallNotification();
+ },
+
+ _hasUnsentMessages: function(room) {
+ return this._getUnsentMessages(room).length > 0;
+ },
+
+ _getUnsentMessages: function(room) {
+ if (!room) { return []; }
+ // TODO: It would be nice if the JS SDK provided nicer constant-time
+ // constructs rather than O(N) (N=num msgs) on this.
+ return room.timeline.filter(function(ev) {
+ return ev.status === Matrix.EventStatus.NOT_SENT;
+ });
+ },
+
+ _updateConfCallNotification: function() {
+ var room = MatrixClientPeg.get().getRoom(this.props.roomId);
+ if (!room || !this.props.ConferenceHandler) {
+ return;
+ }
+ var confMember = room.getMember(
+ this.props.ConferenceHandler.getConferenceUserIdForRoom(this.props.roomId)
+ );
+
+ if (!confMember) {
+ return;
+ }
+ var confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId);
+
+ // A conf call notification should be displayed if there is an ongoing
+ // conf call but this cilent isn't a part of it.
+ this.setState({
+ displayConfCallNotification: (
+ (!confCall || confCall.call_state === "ended") &&
+ confMember.membership === "join"
+ )
+ });
+ },
+
+ 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._updateConfCallNotification();
+ },
+
+ componentDidUpdate: function() {
+ 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});
+ }
+ }
+ },
+
+ 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});
+
+ this.oldScrollHeight = messageWrapperScroll.scrollHeight;
+
+ 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
+ });
+ }
+
+ return true;
+ }
+ return false;
+ },
+
+ onResendAllClick: function() {
+ var eventsToResend = this._getUnsentMessages(this.state.room);
+ eventsToResend.forEach(function(event) {
+ Resend.resend(event);
+ });
+ },
+
+ onJoinButtonClicked: function(ev) {
+ var self = this;
+ MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() {
+ self.setState({
+ joining: false,
+ room: MatrixClientPeg.get().getRoom(self.props.roomId)
+ });
+ }, function(error) {
+ self.setState({
+ joining: false,
+ joinError: error
+ });
+ });
+ this.setState({
+ joining: true
+ });
+ },
+
+ 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.paginating) this.fillSpace();
+ },
+
+ onDragOver: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ ev.dataTransfer.dropEffect = 'none';
+
+ var items = ev.dataTransfer.items;
+ if (items.length == 1) {
+ if (items[0].kind == 'file') {
+ this.setState({ draggingFile : true });
+ ev.dataTransfer.dropEffect = 'copy';
+ }
+ }
+ },
+
+ onDrop: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.setState({ draggingFile : false });
+ var files = ev.dataTransfer.files;
+ if (files.length == 1) {
+ this.uploadFile(files[0]);
+ }
+ },
+
+ onDragLeaveOrEnd: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ this.setState({ draggingFile : false });
+ },
+
+ 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) {
+ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createDialog(ErrorDialog, {
+ title: "Failed to upload file",
+ description: error.toString()
+ });
+ });
+ },
+
+ getWhoIsTypingString: function() {
+ return WhoIsTyping.whoIsTypingString(this.state.room);
+ },
+
+ onSearch: 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
+ ]
+ };
+ }
+
+ 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,
+ }
+ }
+ }
+ }
+ }).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 ];
+ }
+
+ 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()
+ });
+ });
+ },
+
+ getEventTiles: function() {
+ var DateSeparator = sdk.getComponent('molecules.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 (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 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 (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())
+ )
+ {
+ 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 =
+ );
+ } else {
+ var inviteEvent = this.state.room.currentState.members[myUserId].events.member.event;
+ // 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!" : "";
+ return (
+
+ );
+ } else {
+ var typingString = this.getWhoIsTypingString();
+ //typingString = "Testing 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)
+ // It also trumps the "some not sent" msg since you can't resend without
+ // a connection!
+ if (this.state.syncState === "ERROR") {
+ statusBar = (
+
+
+
+
+ Connectivity to the server has been lost.
+
+
+ Sent messages will be stored until your connection has returned.
+
+ );
+ }
+ // unread count trumps who is typing since the unread count is only
+ // set when you've scrolled up
+ else if (unreadMsgs) {
+ statusBar = (
+
+ );
+ }
+});
diff --git a/src/components/structures/login/PostRegistration.js b/src/components/structures/login/PostRegistration.js
new file mode 100644
index 0000000000..5af8d37dd8
--- /dev/null
+++ b/src/components/structures/login/PostRegistration.js
@@ -0,0 +1,80 @@
+/*
+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 sdk = require('../../../index');
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+
+module.exports = React.createClass({
+ displayName: 'PostRegistration',
+
+ propTypes: {
+ onComplete: React.PropTypes.func.isRequired
+ },
+
+ getInitialState: function() {
+ return {
+ avatarUrl: null,
+ errorString: null,
+ busy: false
+ };
+ },
+
+ componentWillMount: function() {
+ // There is some assymetry between ChangeDisplayName and ChangeAvatar,
+ // as ChangeDisplayName will auto-get the name but ChangeAvatar expects
+ // the URL to be passed to you (because it's also used for room avatars).
+ var cli = MatrixClientPeg.get();
+ this.setState({busy: true});
+ var self = this;
+ cli.getProfileInfo(cli.credentials.userId).done(function(result) {
+ self.setState({
+ avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url),
+ busy: false
+ });
+ }, function(error) {
+ self.setState({
+ errorString: "Failed to fetch avatar URL",
+ busy: false
+ });
+ });
+ },
+
+ render: function() {
+ var ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName');
+ var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
+ return (
+
+
+
+
+
+
+ Set a display name:
+
+ Upload an avatar:
+
+
+ {this.state.errorString}
+
+
+
+ );
+ }
+});
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js
new file mode 100644
index 0000000000..0cb1a87752
--- /dev/null
+++ b/src/components/structures/login/Registration.js
@@ -0,0 +1,248 @@
+/*
+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 sdk = require('../../../index');
+var MatrixClientPeg = require('../../../MatrixClientPeg');
+var dis = require('../../../dispatcher');
+var Signup = require("../../../Signup");
+var ServerConfig = require("../../views/login/ServerConfig");
+var RegistrationForm = require("../../views/login/RegistrationForm");
+var CaptchaForm = require("../../views/login/CaptchaForm");
+
+var MIN_PASSWORD_LENGTH = 6;
+
+module.exports = React.createClass({
+ displayName: 'Registration',
+
+ propTypes: {
+ onLoggedIn: React.PropTypes.func.isRequired,
+ clientSecret: React.PropTypes.string,
+ sessionId: React.PropTypes.string,
+ registrationUrl: React.PropTypes.string,
+ idSid: React.PropTypes.string,
+ hsUrl: React.PropTypes.string,
+ isUrl: React.PropTypes.string,
+ // registration shouldn't know or care how login is done.
+ onLoginClick: React.PropTypes.func.isRequired
+ },
+
+ getInitialState: function() {
+ return {
+ busy: false,
+ errorText: null,
+ enteredHomeserverUrl: this.props.hsUrl,
+ enteredIdentityServerUrl: this.props.isUrl
+ };
+ },
+
+ componentWillMount: function() {
+ this.dispatcherRef = dis.register(this.onAction);
+ // attach this to the instance rather than this.state since it isn't UI
+ this.registerLogic = new Signup.Register(
+ this.props.hsUrl, this.props.isUrl
+ );
+ this.registerLogic.setClientSecret(this.props.clientSecret);
+ this.registerLogic.setSessionId(this.props.sessionId);
+ this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
+ this.registerLogic.setIdSid(this.props.idSid);
+ this.registerLogic.recheckState();
+ },
+
+ componentWillUnmount: function() {
+ dis.unregister(this.dispatcherRef);
+ },
+
+ componentDidMount: function() {
+ // may have already done an HTTP hit (e.g. redirect from an email) so
+ // check for any pending response
+ var promise = this.registerLogic.getPromise();
+ if (promise) {
+ this.onProcessingRegistration(promise);
+ }
+ },
+
+ onHsUrlChanged: function(newHsUrl) {
+ this.registerLogic.setHomeserverUrl(newHsUrl);
+ },
+
+ onIsUrlChanged: function(newIsUrl) {
+ this.registerLogic.setIdentityServerUrl(newIsUrl);
+ },
+
+ onAction: function(payload) {
+ if (payload.action !== "registration_step_update") {
+ return;
+ }
+ this.forceUpdate(); // registration state has changed.
+ },
+
+ onFormSubmit: function(formVals) {
+ var self = this;
+ this.setState({
+ errorText: "",
+ busy: true
+ });
+ this.onProcessingRegistration(this.registerLogic.register(formVals));
+ },
+
+ // Promise is resolved when the registration process is FULLY COMPLETE
+ onProcessingRegistration: function(promise) {
+ var self = this;
+ promise.done(function(response) {
+ if (!response || !response.access_token) {
+ console.warn(
+ "FIXME: Register fulfilled without a final response, " +
+ "did you break the promise chain?"
+ );
+ // no matter, we'll grab it direct
+ response = self.registerLogic.getCredentials();
+ }
+ if (!response || !response.user_id || !response.access_token) {
+ console.error("Final response is missing keys.");
+ self.setState({
+ errorText: "There was a problem processing the response."
+ });
+ return;
+ }
+ self.props.onLoggedIn({
+ userId: response.user_id,
+ homeserverUrl: self.registerLogic.getHomeserverUrl(),
+ identityServerUrl: self.registerLogic.getIdentityServerUrl(),
+ accessToken: response.access_token
+ });
+ self.setState({
+ busy: false
+ });
+ }, function(err) {
+ if (err.message) {
+ self.setState({
+ errorText: err.message
+ });
+ }
+ self.setState({
+ busy: false
+ });
+ console.log(err);
+ });
+ },
+
+ onFormValidationFailed: function(errCode) {
+ var errMsg;
+ switch (errCode) {
+ case "RegistrationForm.ERR_PASSWORD_MISSING":
+ errMsg = "Missing password.";
+ break;
+ case "RegistrationForm.ERR_PASSWORD_MISMATCH":
+ errMsg = "Passwords don't match.";
+ break;
+ case "RegistrationForm.ERR_PASSWORD_LENGTH":
+ errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`;
+ break;
+ default:
+ console.error("Unknown error code: %s", errCode);
+ errMsg = "An unknown error occurred.";
+ break;
+ }
+ this.setState({
+ errorText: errMsg
+ });
+ },
+
+ onCaptchaLoaded: function(divIdName) {
+ this.registerLogic.tellStage("m.login.recaptcha", {
+ divId: divIdName
+ });
+ this.setState({
+ busy: false // requires user input
+ });
+ },
+
+ _getRegisterContentJsx: function() {
+ var currStep = this.registerLogic.getStep();
+ var registerStep;
+ switch (currStep) {
+ case "Register.COMPLETE":
+ break; // NOP
+ case "Register.START":
+ case "Register.STEP_m.login.dummy":
+ registerStep = (
+
+ );
+ break;
+ case "Register.STEP_m.login.email.identity":
+ registerStep = (
+
+ Please check your email to continue registration.
+
+ );
+ }
+});
diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js
new file mode 100644
index 0000000000..5c4887955b
--- /dev/null
+++ b/src/components/views/login/RegistrationForm.js
@@ -0,0 +1,126 @@
+/*
+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 sdk = require('../../../index');
+
+/**
+ * A pure UI component which displays a registration form.
+ */
+module.exports = React.createClass({
+ displayName: 'RegistrationForm',
+
+ propTypes: {
+ defaultEmail: React.PropTypes.string,
+ defaultUsername: React.PropTypes.string,
+ showEmail: React.PropTypes.bool,
+ minPasswordLength: React.PropTypes.number,
+ onError: React.PropTypes.func,
+ onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise
+ },
+
+ getDefaultProps: function() {
+ return {
+ showEmail: false,
+ minPasswordLength: 6,
+ onError: function(e) {
+ console.error(e);
+ }
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ email: this.props.defaultEmail,
+ username: this.props.defaultUsername,
+ password: null,
+ passwordConfirm: null
+ };
+ },
+
+ onSubmit: function(ev) {
+ ev.preventDefault();
+
+ var pwd1 = this.refs.password.value.trim();
+ var pwd2 = this.refs.passwordConfirm.value.trim()
+
+ var errCode;
+ if (!pwd1 || !pwd2) {
+ errCode = "RegistrationForm.ERR_PASSWORD_MISSING";
+ }
+ else if (pwd1 !== pwd2) {
+ errCode = "RegistrationForm.ERR_PASSWORD_MISMATCH";
+ }
+ else if (pwd1.length < this.props.minPasswordLength) {
+ errCode = "RegistrationForm.ERR_PASSWORD_LENGTH";
+ }
+ if (errCode) {
+ this.props.onError(errCode);
+ return;
+ }
+
+ var promise = this.props.onRegisterClick({
+ username: this.refs.username.value.trim(),
+ password: pwd1,
+ email: this.refs.email.value.trim()
+ });
+
+ if (promise) {
+ ev.target.disabled = true;
+ promise.finally(function() {
+ ev.target.disabled = false;
+ });
+ }
+ },
+
+ render: function() {
+ var emailSection, registerButton;
+ if (this.props.showEmail) {
+ emailSection = (
+
+ );
+ }
+ if (this.props.onRegisterClick) {
+ registerButton = (
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+});
diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js
new file mode 100644
index 0000000000..39f9dc4594
--- /dev/null
+++ b/src/components/views/login/ServerConfig.js
@@ -0,0 +1,161 @@
+/*
+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 Modal = require('../../../Modal');
+var sdk = require('../../../index');
+
+/**
+ * A pure UI component which displays the HS and IS to use.
+ */
+module.exports = React.createClass({
+ displayName: 'ServerConfig',
+
+ propTypes: {
+ onHsUrlChanged: React.PropTypes.func,
+ onIsUrlChanged: React.PropTypes.func,
+ defaultHsUrl: React.PropTypes.string,
+ defaultIsUrl: React.PropTypes.string,
+ withToggleButton: React.PropTypes.bool,
+ delayTimeMs: React.PropTypes.number // time to wait before invoking onChanged
+ },
+
+ getDefaultProps: function() {
+ return {
+ onHsUrlChanged: function() {},
+ onIsUrlChanged: function() {},
+ withToggleButton: false,
+ delayTimeMs: 0
+ };
+ },
+
+ getInitialState: function() {
+ return {
+ hs_url: this.props.defaultHsUrl,
+ is_url: this.props.defaultIsUrl,
+ original_hs_url: this.props.defaultHsUrl,
+ original_is_url: this.props.defaultIsUrl,
+ // no toggle button = show, toggle button = hide
+ configVisible: !this.props.withToggleButton
+ }
+ },
+
+ onHomeserverChanged: function(ev) {
+ this.setState({hs_url: ev.target.value}, function() {
+ this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() {
+ this.props.onHsUrlChanged(this.state.hs_url);
+ });
+ });
+ },
+
+ onIdentityServerChanged: function(ev) {
+ this.setState({is_url: ev.target.value}, function() {
+ this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() {
+ this.props.onIsUrlChanged(this.state.is_url);
+ });
+ });
+ },
+
+ _waitThenInvoke: function(existingTimeoutId, fn) {
+ if (existingTimeoutId) {
+ clearTimeout(existingTimeoutId);
+ }
+ return setTimeout(fn.bind(this), this.props.delayTimeMs);
+ },
+
+ getHsUrl: function() {
+ return this.state.hs_url;
+ },
+
+ getIsUrl: function() {
+ return this.state.is_url;
+ },
+
+ onServerConfigVisibleChange: function(ev) {
+ this.setState({
+ configVisible: ev.target.checked
+ });
+ },
+
+ showHelpPopup: function() {
+ var ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
+ Modal.createDialog(ErrorDialog, {
+ title: 'Custom Server Options',
+ description:
+ You can use the custom server options to log into other Matrix
+ servers by specifying a different Home server URL.
+
+ This allows you to use Vector with an existing Matrix account on
+ a different Home server.
+
+
+ You can also set a custom Identity server but this will affect
+ people's ability to find you if you use a server in a group other
+ than the main Matrix.org group.
+ ,
+ button: "Dismiss",
+ focus: true
+ });
+ },
+
+ render: function() {
+ var serverConfigStyle = {};
+ serverConfigStyle.display = this.state.configVisible ? 'block' : 'none';
+
+ var toggleButton;
+ if (this.props.withToggleButton) {
+ toggleButton = (
+