diff --git a/src/PasswordReset.js b/src/PasswordReset.js new file mode 100644 index 0000000000000000000000000000000000000000..1029b07b709a0214788abb971be9b366e3822fce --- /dev/null +++ b/src/PasswordReset.js @@ -0,0 +1,104 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var Matrix = require("matrix-js-sdk"); + +/** + * Allows a user to reset their password on a homeserver. + * + * This involves getting an email token from the identity server to "prove" that + * the client owns the given email address, which is then passed to the password + * API on the homeserver in question with the new password. + */ +class PasswordReset { + + /** + * Configure the endpoints for password resetting. + * @param {string} homeserverUrl The URL to the HS which has the account to reset. + * @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping. + */ + constructor(homeserverUrl, identityUrl) { + this.client = Matrix.createClient({ + baseUrl: homeserverUrl, + idBaseUrl: identityUrl + }); + this.clientSecret = generateClientSecret(); + this.identityServerDomain = identityUrl.split("://")[1]; + } + + /** + * Attempt to reset the user's password. This will trigger a side-effect of + * sending an email to the provided email address. + * @param {string} emailAddress The email address + * @param {string} newPassword The new password for the account. + * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). + */ + resetPassword(emailAddress, newPassword) { + this.password = newPassword; + return this.client.requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { + this.sessionId = res.sid; + return res; + }, function(err) { + if (err.httpStatus) { + err.message = err.message + ` (Status ${err.httpStatus})`; + } + throw err; + }); + } + + /** + * Checks if the email link has been clicked by attempting to change the password + * for the mxid linked to the email. + * @return {Promise} Resolves if the password was reset. Rejects with an object + * with a "message" property which contains a human-readable message detailing why + * the reset failed, e.g. "There is no mapped matrix user ID for the given email address". + */ + checkEmailLinkClicked() { + return this.client.setPassword({ + type: "m.login.email.identity", + threepid_creds: { + sid: this.sessionId, + client_secret: this.clientSecret, + id_server: this.identityServerDomain + } + }, this.password).catch(function(err) { + if (err.httpStatus === 401) { + err.message = "Failed to verify email address: make sure you clicked the link in the email"; + } + else if (err.httpStatus === 404) { + err.message = "Your email address does not appear to be associated with a Matrix ID on this Homeserver."; + } + else if (err.httpStatus) { + err.message += ` (Status ${err.httpStatus})`; + } + throw err; + }); + } +} + +// from Angular SDK +function generateClientSecret() { + var ret = ""; + var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (var i = 0; i < 32; i++) { + ret += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + return ret; +} + +module.exports = PasswordReset; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 363560f0c6073cd7e273debd20bb9704cb5ee3e7..1dd7ecb08fecbaf6fa663eb61da7cde7e0e94cb7 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -43,11 +43,27 @@ var commands = { return reject("Usage: /nick <display_name>"); }, - // Takes an #rrggbb colourcode and retints the UI (just for debugging) + // Changes the colorscheme of your current room tint: function(room_id, args) { - Tinter.tint(args); - return success(); - }, + + if (args) { + var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); + if (matches) { + Tinter.tint(matches[1], matches[4]); + var colorScheme = {} + colorScheme.primary_color = matches[1]; + if (matches[4]) { + colorScheme.secondary_color = matches[4]; + } + return success( + MatrixClientPeg.get().setRoomAccountData( + room_id, "org.matrix.room.color_scheme", colorScheme + ) + ); + } + } + return reject("Usage: /tint <primaryColor> [<secondaryColor>]"); + }, encrypt: function(room_id, args) { if (args == "on") { diff --git a/src/Tinter.js b/src/Tinter.js index 7245a5825b11a8a753491864cf0e1fcb2ba98275..3e7949b65dd5d7c0f64f8ba9c2762f04e942cea4 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -127,6 +127,11 @@ module.exports = { cached = true; } + if (!primaryColor) { + primaryColor = "#76CFA6"; // Vector green + secondaryColor = "#EAF5F0"; // Vector light green + } + if (!secondaryColor) { var x = 0.16; // average weighting factor calculated from vector green & light green var rgb = hexToRgb(primaryColor); @@ -146,6 +151,13 @@ module.exports = { tertiaryColor = rgbToHex(rgb1); } + if (colors[0] === primaryColor && + colors[1] === secondaryColor && + colors[2] === tertiaryColor) + { + return; + } + colors = [primaryColor, secondaryColor, tertiaryColor]; // go through manually fixing up the stylesheets. diff --git a/src/UserActivity.js b/src/UserActivity.js index 3048ad4454792f9bc3f4d0fc5438bd4dee867399..8b136c0bcc1d6796184d6607a992d6cebf5ac1da 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -31,6 +31,11 @@ class UserActivity { start() { document.onmousemove = this._onUserActivity.bind(this); document.onkeypress = this._onUserActivity.bind(this); + // can't use document.scroll here because that's only the document + // itself being scrolled. Need to use addEventListener's useCapture. + // also this needs to be the wheel event, not scroll, as scroll is + // fired when the view scrolls down for a new message. + window.addEventListener('wheel', this._onUserActivity.bind(this), true); this.lastActivityAtTs = new Date().getTime(); this.lastDispatchAtTs = 0; } @@ -41,10 +46,11 @@ class UserActivity { stop() { document.onmousemove = undefined; document.onkeypress = undefined; + window.removeEventListener('wheel', this._onUserActivity.bind(this), true); } _onUserActivity(event) { - if (event.screenX) { + if (event.screenX && event.type == "mousemove") { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { diff --git a/src/component-index.js b/src/component-index.js index cef1b093a4b7d04b636ad4a6f63a8cce23ca8ae5..9fe15adfc62b6cbc8d9c03dfa40e4ad73bf5adb0 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -23,6 +23,7 @@ limitations under the License. module.exports.components = {}; module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); +module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword'); module.exports.components['structures.login.Login'] = require('./components/structures/login/Login'); module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 320dad09b3df8c8db194ac1489a62909594099b7..e5af2a86b550686c5d953e0fc6538210caf5bf78 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -30,6 +30,7 @@ var Registration = require("./login/Registration"); var PostRegistration = require("./login/PostRegistration"); var Modal = require("../../Modal"); +var Tinter = require("../../Tinter"); var sdk = require('../../index'); var MatrixTools = require('../../MatrixTools'); var linkifyMatrix = require("../../linkify-matrix"); @@ -233,6 +234,13 @@ module.exports = React.createClass({ }); this.notifyNewScreen('register'); break; + case 'start_password_recovery': + if (this.state.logged_in) return; + this.replaceState({ + screen: 'forgot_password' + }); + this.notifyNewScreen('forgot_password'); + break; case 'token_login': if (this.state.logged_in) return; @@ -411,7 +419,16 @@ module.exports = React.createClass({ if (room) { var theAlias = MatrixTools.getCanonicalAliasForRoom(room); if (theAlias) presentedId = theAlias; + + var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme"); + var color_scheme = {}; + if (color_scheme_event) { + color_scheme = color_scheme_event.getContent(); + // XXX: we should validate the event + } + Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); } + this.notifyNewScreen('room/'+presentedId); newState.ready = true; } @@ -559,6 +576,11 @@ module.exports = React.createClass({ action: 'token_login', params: params }); + } else if (screen == 'forgot_password') { + dis.dispatch({ + action: 'start_password_recovery', + params: params + }); } else if (screen == 'new') { dis.dispatch({ action: 'view_create_room', @@ -668,6 +690,10 @@ module.exports = React.createClass({ this.showScreen("login"); }, + onForgotPasswordClick: function() { + this.showScreen("forgot_password"); + }, + onRegistered: function(credentials) { this.onLoggedIn(credentials); // do post-registration stuff @@ -706,6 +732,7 @@ module.exports = React.createClass({ var CreateRoom = sdk.getComponent('structures.CreateRoom'); var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); + var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); // needs to be before normal PageTypes as you are logged in technically if (this.state.screen == 'post_registration') { @@ -801,13 +828,21 @@ module.exports = React.createClass({ onLoggedIn={this.onRegistered} onLoginClick={this.onLoginClick} /> ); + } else if (this.state.screen == 'forgot_password') { + return ( + <ForgotPassword + homeserverUrl={this.props.config.default_hs_url} + identityServerUrl={this.props.config.default_is_url} + onComplete={this.onLoginClick} /> + ); } else { return ( <Login onLoggedIn={this.onLoggedIn} onRegisterClick={this.onRegisterClick} homeserverUrl={this.props.config.default_hs_url} - identityServerUrl={this.props.config.default_is_url} /> + identityServerUrl={this.props.config.default_is_url} + onForgotPasswordClick={this.onForgotPasswordClick} /> ); } } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 7a729a4436c86266b9b18ccb243d53dd19ba4d06..ddde9c2645b00e6b0b9f139ef52b89e871eb4999 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -37,9 +37,11 @@ var TabComplete = require("../../TabComplete"); var MemberEntry = require("../../TabCompleteEntries").MemberEntry; var Resend = require("../../Resend"); var dis = require("../../dispatcher"); +var Tinter = require("../../Tinter"); var PAGINATE_SIZE = 20; var INITIAL_SIZE = 20; +var SEND_READ_RECEIPT_DELAY = 2000; var DEBUG_SCROLL = false; @@ -74,7 +76,9 @@ module.exports = React.createClass({ syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, - guestsCanJoin: false + guestsCanJoin: false, + readMarkerEventId: room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId), + readMarkerGhostEventId: undefined } }, @@ -82,6 +86,7 @@ module.exports = React.createClass({ this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); + MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); @@ -152,6 +157,7 @@ module.exports = React.createClass({ if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); + MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); @@ -159,6 +165,8 @@ module.exports = React.createClass({ } window.removeEventListener('resize', this.onResize); + + Tinter.tint(); // reset colourscheme }, onAction: function(payload) { @@ -272,9 +280,58 @@ module.exports = React.createClass({ } }, + updateTint: function() { + var room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!room) return; + + var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme"); + var color_scheme = {}; + if (color_scheme_event) { + color_scheme = color_scheme_event.getContent(); + // XXX: we should validate the event + } + Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); + }, + + onRoomAccountData: function(room, event) { + if (room.roomId == this.props.roomId) { + if (event.getType === "org.matrix.room.color_scheme") { + var color_scheme = event.getContent(); + // XXX: we should validate the event + Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); + } + } + }, + onRoomReceipt: function(receiptEvent, room) { if (room.roomId == this.props.roomId) { - this.forceUpdate(); + var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); + var readMarkerGhostEventId = this.state.readMarkerGhostEventId; + if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) { + readMarkerGhostEventId = this.state.readMarkerEventId; + } + + + // if the event after the one referenced in the read receipt if sent by us, do nothing since + // this is a temporary period before the synthesized receipt for our own message arrives + var readMarkerGhostEventIndex; + for (var i = 0; i < room.timeline.length; ++i) { + if (room.timeline[i].getId() == readMarkerGhostEventId) { + readMarkerGhostEventIndex = i; + break; + } + } + if (readMarkerGhostEventIndex + 1 < room.timeline.length) { + var nextEvent = room.timeline[readMarkerGhostEventIndex + 1]; + if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) { + readMarkerGhostEventId = undefined; + } + } + + this.setState({ + readMarkerEventId: readMarkerEventId, + readMarkerGhostEventId: readMarkerGhostEventId, + }); } }, @@ -374,6 +431,8 @@ module.exports = React.createClass({ this.scrollToBottom(); this.sendReadReceipt(); + + this.updateTint(); }, componentDidUpdate: function() { @@ -695,10 +754,10 @@ module.exports = React.createClass({ var EventTile = sdk.getComponent('rooms.EventTile'); - var prevEvent = null; // the last event we showed - var readReceiptEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap); + var readMarkerIndex; + var ghostIndex; for (var i = startIdx; i < this.state.room.timeline.length; i++) { var mxEv = this.state.room.timeline[i]; @@ -712,6 +771,25 @@ module.exports = React.createClass({ } } + // now we've decided whether or not to show this message, + // add the read up to marker if appropriate + // doing this here means we implicitly do not show the marker + // if it's at the bottom + // NB. it would be better to decide where the read marker was going + // when the state changed rather than here in the render method, but + // this is where we decide what messages we show so it's the only + // place we know whether we're at the bottom or not. + var self = this; + var mxEvSender = mxEv.sender ? mxEv.sender.userId : null; + if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) { + var hr; + hr = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) { + self.readMarkerNode = n; + }} />); + readMarkerIndex = ret.length; + ret.push(<li key="_readupto" className="mx_RoomView_myReadMarker_container">{hr}</li>); + } + // is this a continuation of the previous message? var continuation = false; if (prevEvent !== null) { @@ -748,13 +826,29 @@ module.exports = React.createClass({ </li> ); - if (eventId == readReceiptEventId) { - ret.push(<hr className="mx_RoomView_myReadMarker" />); + // A read up to marker has died and returned as a ghost! + // Lives in the dom as the ghost of the previous one while it fades away + if (eventId == this.state.readMarkerGhostEventId) { + ghostIndex = ret.length; } prevEvent = mxEv; } + // splice the read marker ghost in now that we know whether the read receipt + // is the last element or not, because we only decide as we're going along. + if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) { + var hr; + hr = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '85%'}} ref={function(n) { + Velocity(n, {opacity: '0', width: '10%'}, {duration: 400, easing: 'easeInSine', delay: 1000, complete: function() { + self.setState({readMarkerGhostEventId: undefined}); + }}); + }} />); + ret.splice(ghostIndex, 0, ( + <li key="_readuptoghost" className="mx_RoomView_myReadMarker_container">{hr}</li> + )); + } + return ret; }, @@ -825,6 +919,14 @@ module.exports = React.createClass({ ); } + if (newVals.color_scheme) { + deferreds.push( + MatrixClientPeg.get().setRoomAccountData( + this.state.room.roomId, "org.matrix.room.color_scheme", newVals.color_scheme + ) + ); + } + deferreds.push( MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { allowRead: newVals.guest_read, @@ -923,11 +1025,13 @@ module.exports = React.createClass({ history_visibility: this.refs.room_settings.getHistoryVisibility(), power_levels: this.refs.room_settings.getPowerLevels(), guest_join: this.refs.room_settings.canGuestsJoin(), - guest_read: this.refs.room_settings.canGuestsRead() + guest_read: this.refs.room_settings.canGuestsRead(), + color_scheme: this.refs.room_settings.getColorScheme(), }); }, onCancelClick: function() { + this.updateTint(); this.setState({editingRoomSettings: false}); }, diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js new file mode 100644 index 0000000000000000000000000000000000000000..dcf6a7c28e00ca747d59e8bfe1c73d402f2793d5 --- /dev/null +++ b/src/components/structures/login/ForgotPassword.js @@ -0,0 +1,199 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var React = require('react'); +var sdk = require('../../../index'); +var Modal = require("../../../Modal"); +var MatrixClientPeg = require('../../../MatrixClientPeg'); + +var PasswordReset = require("../../../PasswordReset"); + +module.exports = React.createClass({ + displayName: 'ForgotPassword', + + propTypes: { + homeserverUrl: React.PropTypes.string, + identityServerUrl: React.PropTypes.string, + onComplete: React.PropTypes.func.isRequired + }, + + getInitialState: function() { + return { + enteredHomeserverUrl: this.props.homeserverUrl, + enteredIdentityServerUrl: this.props.identityServerUrl, + progress: null + }; + }, + + submitPasswordReset: function(hsUrl, identityUrl, email, password) { + this.setState({ + progress: "sending_email" + }); + this.reset = new PasswordReset(hsUrl, identityUrl); + this.reset.resetPassword(email, password).done(() => { + this.setState({ + progress: "sent_email" + }); + }, (err) => { + this.showErrorDialog("Failed to send email: " + err.message); + this.setState({ + progress: null + }); + }) + }, + + onVerify: function(ev) { + ev.preventDefault(); + if (!this.reset) { + console.error("onVerify called before submitPasswordReset!"); + return; + } + this.reset.checkEmailLinkClicked().done((res) => { + this.setState({ progress: "complete" }); + }, (err) => { + this.showErrorDialog(err.message); + }) + }, + + onSubmitForm: function(ev) { + ev.preventDefault(); + + if (!this.state.email) { + this.showErrorDialog("The email address linked to your account must be entered."); + } + else if (!this.state.password || !this.state.password2) { + this.showErrorDialog("A new password must be entered."); + } + else if (this.state.password !== this.state.password2) { + this.showErrorDialog("New passwords must match each other."); + } + else { + this.submitPasswordReset( + this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl, + this.state.email, this.state.password + ); + } + }, + + onInputChanged: function(stateKey, ev) { + this.setState({ + [stateKey]: ev.target.value + }); + }, + + onHsUrlChanged: function(newHsUrl) { + this.setState({ + enteredHomeserverUrl: newHsUrl + }); + }, + + onIsUrlChanged: function(newIsUrl) { + this.setState({ + enteredIdentityServerUrl: newIsUrl + }); + }, + + showErrorDialog: function(body, title) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: title, + description: body + }); + }, + + render: function() { + var LoginHeader = sdk.getComponent("login.LoginHeader"); + var LoginFooter = sdk.getComponent("login.LoginFooter"); + var ServerConfig = sdk.getComponent("login.ServerConfig"); + var Spinner = sdk.getComponent("elements.Spinner"); + + var resetPasswordJsx; + + if (this.state.progress === "sending_email") { + resetPasswordJsx = <Spinner /> + } + else if (this.state.progress === "sent_email") { + resetPasswordJsx = ( + <div> + An email has been sent to {this.state.email}. Once you've followed + the link it contains, click below. + <br /> + <input className="mx_Login_submit" type="button" onClick={this.onVerify} + value="I have verified my email address" /> + </div> + ); + } + else if (this.state.progress === "complete") { + resetPasswordJsx = ( + <div> + <p>Your password has been reset.</p> + <p>You have been logged out of all devices and will no longer receive push notifications. + To re-enable notifications, re-log in on each device.</p> + <input className="mx_Login_submit" type="button" onClick={this.props.onComplete} + value="Return to login screen" /> + </div> + ); + } + else { + resetPasswordJsx = ( + <div> + To reset your password, enter the email address linked to your account: + <br /> + <div> + <form onSubmit={this.onSubmitForm}> + <input className="mx_Login_field" ref="user" type="text" + value={this.state.email} + onChange={this.onInputChanged.bind(this, "email")} + placeholder="Email address" autoFocus /> + <br /> + <input className="mx_Login_field" ref="pass" type="password" + value={this.state.password} + onChange={this.onInputChanged.bind(this, "password")} + placeholder="New password" /> + <br /> + <input className="mx_Login_field" ref="pass" type="password" + value={this.state.password2} + onChange={this.onInputChanged.bind(this, "password2")} + placeholder="Confirm your new password" /> + <br /> + <input className="mx_Login_submit" type="submit" value="Send Reset Email" /> + </form> + <ServerConfig ref="serverConfig" + withToggleButton={true} + defaultHsUrl={this.props.homeserverUrl} + defaultIsUrl={this.props.identityServerUrl} + onHsUrlChanged={this.onHsUrlChanged} + onIsUrlChanged={this.onIsUrlChanged} + delayTimeMs={0}/> + <LoginFooter /> + </div> + </div> + ); + } + + + return ( + <div className="mx_Login"> + <div className="mx_Login_box"> + <LoginHeader /> + {resetPasswordJsx} + </div> + </div> + ); + } +}); diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index b7d2d762a472913021681c6fe5fad52ca932f514..b853b8fd95cc8da920b07a18626e530814221e4b 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -33,7 +33,9 @@ module.exports = React.createClass({displayName: 'Login', homeserverUrl: React.PropTypes.string, identityServerUrl: React.PropTypes.string, // login shouldn't know or care how registration is done. - onRegisterClick: React.PropTypes.func.isRequired + onRegisterClick: React.PropTypes.func.isRequired, + // login shouldn't care how password recovery is done. + onForgotPasswordClick: React.PropTypes.func }, getDefaultProps: function() { @@ -138,7 +140,9 @@ module.exports = React.createClass({displayName: 'Login', switch (step) { case 'm.login.password': return ( - <PasswordLogin onSubmit={this.onPasswordLogin} /> + <PasswordLogin + onSubmit={this.onPasswordLogin} + onForgotPasswordClick={this.props.onForgotPasswordClick} /> ); case 'm.login.cas': return ( diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 3367ac32573e6258a0689042159f8e6ae2c4c901..a8751da1a78b94330fd1006797dd919039bdc062 100644 --- a/src/components/views/login/PasswordLogin.js +++ b/src/components/views/login/PasswordLogin.js @@ -22,7 +22,8 @@ var ReactDOM = require('react-dom'); */ module.exports = React.createClass({displayName: 'PasswordLogin', propTypes: { - onSubmit: React.PropTypes.func.isRequired // fn(username, password) + onSubmit: React.PropTypes.func.isRequired, // fn(username, password) + onForgotPasswordClick: React.PropTypes.func // fn() }, getInitialState: function() { @@ -46,6 +47,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin', }, render: function() { + var forgotPasswordJsx; + + if (this.props.onForgotPasswordClick) { + forgotPasswordJsx = ( + <a className="mx_Login_forgot" onClick={this.props.onForgotPasswordClick} href="#"> + Forgot your password? + </a> + ); + } + return ( <div> <form onSubmit={this.onSubmitForm}> @@ -57,6 +68,7 @@ module.exports = React.createClass({displayName: 'PasswordLogin', value={this.state.password} onChange={this.onPasswordChanged} placeholder="Password" /> <br /> + {forgotPasswordJsx} <input className="mx_Login_submit" type="submit" value="Log in" /> </form> </div> diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 8b5435e46a1255d861d003c4b318b1fb7188221d..cdfbc0bfc8729568a6a257bb59e7931742130aae 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -116,7 +116,7 @@ module.exports = React.createClass({ } name = - <div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}> + <div className="mx_RoomHeader_name"> <div className="mx_RoomHeader_nametext" title={ this.props.room.name }>{ this.props.room.name }</div> { searchStatus } <div className="mx_RoomHeader_settingsButton" title="Settings"> @@ -151,7 +151,7 @@ module.exports = React.createClass({ header = <div className="mx_RoomHeader_wrapper"> - <div className="mx_RoomHeader_leftRow"> + <div className="mx_RoomHeader_leftRow" onClick={this.props.onSettingsClick}> <div className="mx_RoomHeader_avatar"> { roomAvatar } </div> diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 9e07385d65548505c67e0fa9a73d24cb2f6b8cc0..c5e37521c5b0c4cbe0be3a957679c5b9124393ed 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -16,8 +16,23 @@ limitations under the License. var React = require('react'); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var Tinter = require('../../../Tinter'); var sdk = require('../../../index'); +var room_colors = [ + // magic room default values courtesy of Ribot + ["#76cfa6", "#eaf5f0"], + ["#81bddb", "#eaf1f4"], + ["#bd79cb", "#f3eaf5"], + ["#c65d94", "#f5eaef"], + ["#e55e5e", "#f5eaea"], + ["#eca46f", "#f5eeea"], + ["#dad658", "#f5f4ea"], + ["#80c553", "#eef5ea"], + ["#bb814e", "#eee8e3"], + ["#595959", "#ececec"], +]; + module.exports = React.createClass({ displayName: 'RoomSettings', @@ -26,8 +41,37 @@ module.exports = React.createClass({ }, getInitialState: function() { + // work out the initial color index + var room_color_index = undefined; + var color_scheme_event = this.props.room.getAccountData("org.matrix.room.color_scheme"); + if (color_scheme_event) { + var color_scheme = color_scheme_event.getContent(); + if (color_scheme.primary_color) color_scheme.primary_color = color_scheme.primary_color.toLowerCase(); + if (color_scheme.secondary_color) color_scheme.secondary_color = color_scheme.secondary_color.toLowerCase(); + // XXX: we should validate these values + for (var i = 0; i < room_colors.length; i++) { + var room_color = room_colors[i]; + if (room_color[0] === color_scheme.primary_color && + room_color[1] === color_scheme.secondary_color) + { + room_color_index = i; + break; + } + } + if (room_color_index === undefined) { + // append the unrecognised colours to our palette + room_color_index = room_colors.length; + room_colors[room_color_index] = [ color_scheme.primary_color, color_scheme.secondary_color ]; + } + } + else { + room_color_index = 0; + } + return { - power_levels_changed: false + power_levels_changed: false, + color_scheme_changed: false, + color_scheme_index: room_color_index, }; }, @@ -78,6 +122,25 @@ module.exports = React.createClass({ }); }, + getColorScheme: function() { + if (!this.state.color_scheme_changed) return undefined; + + return { + primary_color: room_colors[this.state.color_scheme_index][0], + secondary_color: room_colors[this.state.color_scheme_index][1], + }; + }, + + onColorSchemeChanged: function(index) { + // preview what the user just changed the scheme to. + Tinter.tint(room_colors[index][0], room_colors[index][1]); + + this.setState({ + color_scheme_changed: true, + color_scheme_index: index, + }); + }, + render: function() { var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); @@ -151,16 +214,80 @@ module.exports = React.createClass({ } var can_set_room_avatar = current_user_level >= room_avatar_level; + var self = this; + + var room_colors_section = + <div> + <h3>Room Colour</h3> + <div className="mx_RoomSettings_roomColors"> + {room_colors.map(function(room_color, i) { + var selected; + if (i === self.state.color_scheme_index) { + selected = + <div className="mx_RoomSettings_roomColor_selected"> + <img src="img/tick.svg" width="17" height="14" alt="./"/> + </div> + } + var boundClick = self.onColorSchemeChanged.bind(self, i) + return ( + <div className="mx_RoomSettings_roomColor" + key={ "room_color_" + i } + style={{ backgroundColor: room_color[1] }} + onClick={ boundClick }> + { selected } + <div className="mx_RoomSettings_roomColorPrimary" style={{ backgroundColor: room_color[0] }}></div> + </div> + ); + })} + </div> + </div>; + var change_avatar; if (can_set_room_avatar) { - change_avatar = <div> - <h3>Room Icon</h3> - <ChangeAvatar room={this.props.room} /> - </div>; + change_avatar = + <div> + <h3>Room Icon</h3> + <ChangeAvatar room={this.props.room} /> + </div>; } var banned = this.props.room.getMembersWithMembership("ban"); + var events_levels_section; + if (events_levels.length) { + events_levels_section = + <div> + <h3>Event levels</h3> + <div className="mx_RoomSettings_eventLevels mx_RoomSettings_settings"> + {Object.keys(events_levels).map(function(event_type, i) { + return ( + <div key={event_type}> + <label htmlFor={"mx_RoomSettings_event_"+i}>{event_type}</label> + <input type="text" defaultValue={events_levels[event_type]} size="3" id={"mx_RoomSettings_event_"+i} disabled/> + </div> + ); + })} + </div> + </div>; + } + + var banned_users_section; + if (banned.length) { + banned_users_section = + <div> + <h3>Banned users</h3> + <div className="mx_RoomSettings_banned"> + {banned.map(function(member, i) { + return ( + <div key={i}> + {member.userId} + </div> + ); + })} + </div> + </div>; + } + return ( <div className="mx_RoomSettings"> <textarea className="mx_RoomSettings_description" placeholder="Topic" defaultValue={topic} ref="topic"/> <br/> @@ -174,10 +301,13 @@ module.exports = React.createClass({ <input type="checkbox" ref="guests_join" defaultChecked={guest_access === "can_join"}/> Allow guests to join this room </label> <br/> - <label className="mx_RoomSettings_encrypt"><input type="checkbox" /> Encrypt room</label> <br/> + <label className="mx_RoomSettings_encrypt"><input type="checkbox" /> Encrypt room</label> + + { room_colors_section } + <h3>Power levels</h3> - <div className="mx_RoomSettings_power_levels mx_RoomSettings_settings"> + <div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings"> <div> <label htmlFor="mx_RoomSettings_ban_level">Ban level</label> <input type="text" defaultValue={ban_level} size="3" ref="ban" id="mx_RoomSettings_ban_level" @@ -217,7 +347,7 @@ module.exports = React.createClass({ </div> <h3>User levels</h3> - <div className="mx_RoomSettings_user_levels mx_RoomSettings_settings"> + <div className="mx_RoomSettings_userLevels mx_RoomSettings_settings"> {Object.keys(user_levels).map(function(user, i) { return ( <div key={user}> @@ -228,29 +358,9 @@ module.exports = React.createClass({ })} </div> - <h3>Event levels</h3> - <div className="mx_RoomSettings_event_lvels mx_RoomSettings_settings"> - {Object.keys(events_levels).map(function(event_type, i) { - return ( - <div key={event_type}> - <label htmlFor={"mx_RoomSettings_event_"+i}>{event_type}</label> - <input type="text" defaultValue={events_levels[event_type]} size="3" id={"mx_RoomSettings_event_"+i} disabled/> - </div> - ); - })} - </div> - - <h3>Banned users</h3> - <div className="mx_RoomSettings_banned"> - {banned.map(function(member, i) { - return ( - <div key={i}> - {member.userId} - </div> - ); - })} - </div> - {change_avatar} + { events_levels_section } + { banned_users_section } + { change_avatar } </div> ); } diff --git a/src/components/views/settings/ChangeAvatar.js b/src/components/views/settings/ChangeAvatar.js index f024f53a9a7ac8078b6ea6bd8bd605577599a9a9..f5ec6a0467e9fa9ebc00e2625c27ee5ace44eb0d 100644 --- a/src/components/views/settings/ChangeAvatar.js +++ b/src/components/views/settings/ChangeAvatar.js @@ -111,13 +111,14 @@ module.exports = React.createClass({ // Having just set an avatar we just display that since it will take a little // time to propagate through to the RoomAvatar. if (this.props.room && !this.avatarSet) { - avatarImg = <RoomAvatar room={this.props.room} width='320' height='240' resizeMethod='scale' />; + avatarImg = <RoomAvatar room={this.props.room} width='240' height='240' resizeMethod='crop' />; } else { var style = { - maxWidth: 320, + maxWidth: 240, maxHeight: 240, + objectFit: 'cover', }; - avatarImg = <img src={this.state.avatarUrl} style={style} />; + avatarImg = <img className="mx_RoomAvatar" src={this.state.avatarUrl} style={style} />; } var uploadSection;