diff --git a/package.json b/package.json index 9c2c645ea2..ac72744af4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "react-dom": "^0.14.2", "react-gemini-scrollbar": "^2.0.1", "sanitize-html": "^1.11.1", - "velocity-animate": "^1.2.3" + "velocity-animate": "^1.2.3", + "velocity-ui-pack": "^1.2.2" }, "//deps": "The loader packages are here because webpack in a project that depends on us needs them in this package's node_modules folder", "//depsbuglink": "https://github.com/webpack/webpack/issues/1472", diff --git a/src/PasswordReset.js b/src/PasswordReset.js new file mode 100644 index 0000000000..1029b07b70 --- /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/Signup.js b/src/Signup.js index 42468959fe..fbc2a09634 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -152,6 +152,8 @@ class Register extends Signup { } else { if (error.errcode === 'M_USER_IN_USE') { throw new Error("Username in use"); + } else if (error.errcode == 'M_INVALID_USERNAME') { + throw new Error("User names may only contain alphanumeric characters, underscores or dots!"); } else if (error.httpStatus == 401) { throw new Error("Authorisation failed!"); } else if (error.httpStatus >= 400 && error.httpStatus < 500) { diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 363560f0c6..d4e7df3a16 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -20,6 +20,31 @@ var dis = require("./dispatcher"); var encryption = require("./encryption"); var Tinter = require("./Tinter"); + +class Command { + constructor(name, paramArgs, runFn) { + this.name = name; + this.paramArgs = paramArgs; + this.runFn = runFn; + } + + getCommand() { + return "/" + this.name; + } + + getCommandWithArgs() { + return this.getCommand() + " " + this.paramArgs; + } + + run(roomId, args) { + return this.runFn.bind(this)(roomId, args); + } + + getUsage() { + return "Usage: " + this.getCommandWithArgs() + } +} + var reject = function(msg) { return { error: msg @@ -34,22 +59,37 @@ var success = function(promise) { var commands = { // Change your nickname - nick: function(room_id, args) { + nick: new Command("nick", "", function(room_id, args) { if (args) { return success( MatrixClientPeg.get().setDisplayName(args) ); } - return reject("Usage: /nick "); - }, + return reject(this.getUsage()); + }), - // Takes an #rrggbb colourcode and retints the UI (just for debugging) - tint: function(room_id, args) { - Tinter.tint(args); - return success(); - }, + // Changes the colorscheme of your current room + tint: new Command("tint", " []", function(room_id, args) { + 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(this.getUsage()); + }), - encrypt: function(room_id, args) { + encrypt: new Command("encrypt", "", function(room_id, args) { if (args == "on") { var client = MatrixClientPeg.get(); var members = client.getRoom(room_id).currentState.members; @@ -65,21 +105,21 @@ var commands = { ); } - return reject("Usage: encrypt "); - }, + return reject(this.getUsage()); + }), // Change the room topic - topic: function(room_id, args) { + topic: new Command("topic", "", function(room_id, args) { if (args) { return success( MatrixClientPeg.get().setRoomTopic(room_id, args) ); } - return reject("Usage: /topic "); - }, + return reject(this.getUsage()); + }), // Invite a user - invite: function(room_id, args) { + invite: new Command("invite", "", function(room_id, args) { if (args) { var matches = args.match(/^(\S+)$/); if (matches) { @@ -88,11 +128,11 @@ var commands = { ); } } - return reject("Usage: /invite "); - }, + return reject(this.getUsage()); + }), // Join a room - join: function(room_id, args) { + join: new Command("join", "", function(room_id, args) { if (args) { var matches = args.match(/^(\S+)$/); if (matches) { @@ -101,8 +141,7 @@ var commands = { return reject("Usage: /join #alias:domain"); } if (!room_alias.match(/:/)) { - var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); - room_alias += ':' + domain; + room_alias += ':' + MatrixClientPeg.get().getDomain(); } // Try to find a room with this alias @@ -135,21 +174,20 @@ var commands = { ); } } - return reject("Usage: /join "); - }, + return reject(this.getUsage()); + }), - part: function(room_id, args) { + part: new Command("part", "[#alias:domain]", function(room_id, args) { var targetRoomId; if (args) { var matches = args.match(/^(\S+)$/); if (matches) { var room_alias = matches[1]; if (room_alias[0] !== '#') { - return reject("Usage: /part [#alias:domain]"); + return reject(this.getUsage()); } if (!room_alias.match(/:/)) { - var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); - room_alias += ':' + domain; + room_alias += ':' + MatrixClientPeg.get().getDomain(); } // Try to find a room with this alias @@ -182,10 +220,10 @@ var commands = { dis.dispatch({action: 'view_next_room'}); }) ); - }, + }), // Kick a user from the room with an optional reason - kick: function(room_id, args) { + kick: new Command("kick", " []", function(room_id, args) { if (args) { var matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { @@ -194,11 +232,11 @@ var commands = { ); } } - return reject("Usage: /kick []"); - }, + return reject(this.getUsage()); + }), // Ban a user from the room with an optional reason - ban: function(room_id, args) { + ban: new Command("ban", " []", function(room_id, args) { if (args) { var matches = args.match(/^(\S+?)( +(.*))?$/); if (matches) { @@ -207,11 +245,11 @@ var commands = { ); } } - return reject("Usage: /ban []"); - }, + return reject(this.getUsage()); + }), // Unban a user from the room - unban: function(room_id, args) { + unban: new Command("unban", "", function(room_id, args) { if (args) { var matches = args.match(/^(\S+)$/); if (matches) { @@ -221,11 +259,11 @@ var commands = { ); } } - return reject("Usage: /unban "); - }, + return reject(this.getUsage()); + }), // Define the power level of a user - op: function(room_id, args) { + op: new Command("op", " []", function(room_id, args) { if (args) { var matches = args.match(/^(\S+?)( +(\d+))?$/); var powerLevel = 50; // default power level for op @@ -250,11 +288,11 @@ var commands = { } } } - return reject("Usage: /op []"); - }, + return reject(this.getUsage()); + }), // Reset the power level of a user - deop: function(room_id, args) { + deop: new Command("deop", "", function(room_id, args) { if (args) { var matches = args.match(/^(\S+)$/); if (matches) { @@ -273,12 +311,14 @@ var commands = { ); } } - return reject("Usage: /deop "); - } + return reject(this.getUsage()); + }) }; // helpful aliases -commands.j = commands.join; +var aliases = { + j: "join" +} module.exports = { /** @@ -298,13 +338,26 @@ module.exports = { var cmd = bits[1].substring(1).toLowerCase(); var args = bits[3]; if (cmd === "me") return null; + if (aliases[cmd]) { + cmd = aliases[cmd]; + } if (commands[cmd]) { - return commands[cmd](roomId, args); + return commands[cmd].run(roomId, args); } else { return reject("Unrecognised command: " + input); } } return null; // not a command + }, + + getCommandList: function() { + // Return all the commands plus /me which isn't handled like normal commands + var cmds = Object.keys(commands).sort().map(function(cmdKey) { + return commands[cmdKey]; + }) + cmds.push(new Command("me", "", function(){})); + + return cmds; } }; diff --git a/src/TabComplete.js b/src/TabComplete.js index 6690802d5d..8886e21af9 100644 --- a/src/TabComplete.js +++ b/src/TabComplete.js @@ -32,8 +32,6 @@ const MATCH_REGEX = /(^|\s)(\S+)$/; class TabComplete { constructor(opts) { - opts.startingWordSuffix = opts.startingWordSuffix || ""; - opts.wordSuffix = opts.wordSuffix || ""; opts.allowLooping = opts.allowLooping || false; opts.autoEnterTabComplete = opts.autoEnterTabComplete || false; opts.onClickCompletes = opts.onClickCompletes || false; @@ -58,7 +56,7 @@ class TabComplete { // assign onClick listeners for each entry to complete the text this.list.forEach((l) => { l.onClick = () => { - this.completeTo(l.getText()); + this.completeTo(l); } }); } @@ -93,10 +91,12 @@ class TabComplete { /** * Do an auto-complete with the given word. This terminates the tab-complete. - * @param {string} someVal + * @param {Entry} entry The tab-complete entry to complete to. */ - completeTo(someVal) { - this.textArea.value = this._replaceWith(someVal, true); + completeTo(entry) { + this.textArea.value = this._replaceWith( + entry.getFillText(), true, entry.getSuffix(this.isFirstWord) + ); this.stopTabCompleting(); // keep focus on the text area this.textArea.focus(); @@ -222,8 +222,9 @@ class TabComplete { if (!this.inPassiveMode) { // set textarea to this new value this.textArea.value = this._replaceWith( - this.matchedList[this.currentIndex].text, - this.currentIndex !== 0 // don't suffix the original text! + this.matchedList[this.currentIndex].getFillText(), + this.currentIndex !== 0, // don't suffix the original text! + this.matchedList[this.currentIndex].getSuffix(this.isFirstWord) ); } @@ -243,7 +244,7 @@ class TabComplete { } } - _replaceWith(newVal, includeSuffix) { + _replaceWith(newVal, includeSuffix, suffix) { // The regex to replace the input matches a character of whitespace AND // the partial word. If we just use string.replace() with the regex it will // replace the partial word AND the character of whitespace. We want to @@ -258,13 +259,12 @@ class TabComplete { boundaryChar = ""; } - var replacementText = ( - boundaryChar + newVal + ( - includeSuffix ? - (this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix) : - "" - ) - ); + suffix = suffix || ""; + if (!includeSuffix) { + suffix = ""; + } + + var replacementText = boundaryChar + newVal + suffix; return this.originalText.replace(MATCH_REGEX, function() { return replacementText; // function form to avoid `$` special-casing }); diff --git a/src/TabCompleteEntries.js b/src/TabCompleteEntries.js index d3efc0d2f1..9aef7736a8 100644 --- a/src/TabCompleteEntries.js +++ b/src/TabCompleteEntries.js @@ -28,6 +28,14 @@ class Entry { return this.text; } + /** + * @return {string} The text to insert into the input box. Most of the time + * this is the same as getText(). + */ + getFillText() { + return this.text; + } + /** * @return {ReactClass} Raw JSX */ @@ -42,6 +50,14 @@ class Entry { return null; } + /** + * @return {?string} The suffix to append to the tab-complete, or null to + * not do this. + */ + getSuffix(isFirstWord) { + return null; + } + /** * Called when this entry is clicked. */ @@ -50,6 +66,31 @@ class Entry { } } +class CommandEntry extends Entry { + constructor(cmd, cmdWithArgs) { + super(cmdWithArgs); + this.cmd = cmd; + } + + getFillText() { + return this.cmd; + } + + getKey() { + return this.getFillText(); + } + + getSuffix(isFirstWord) { + return " "; // force a space after the command. + } +} + +CommandEntry.fromCommands = function(commandArray) { + return commandArray.map(function(cmd) { + return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs()); + }); +} + class MemberEntry extends Entry { constructor(member) { super(member.name || member.userId); @@ -66,6 +107,10 @@ class MemberEntry extends Entry { getKey() { return this.member.userId; } + + getSuffix(isFirstWord) { + return isFirstWord ? ": " : " "; + } } MemberEntry.fromMemberList = function(members) { @@ -99,3 +144,4 @@ MemberEntry.fromMemberList = function(members) { module.exports.Entry = Entry; module.exports.MemberEntry = MemberEntry; +module.exports.CommandEntry = CommandEntry; diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 5296ef833e..f2ae22a1bb 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -66,7 +66,7 @@ function textForMemberEvent(ev) { function textForTopicEvent(ev) { var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return senderDisplayName + ' changed the topic to, "' + ev.getContent().topic + '"'; + return senderDisplayName + ' changed the topic to "' + ev.getContent().topic + '"'; }; function textForRoomNameEvent(ev) { diff --git a/src/Tinter.js b/src/Tinter.js index 7245a5825b..3e7949b65d 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 3048ad4454..669b007934 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.js @@ -16,7 +16,8 @@ limitations under the License. var dis = require("./dispatcher"); -var MIN_DISPATCH_INTERVAL = 1 * 1000; +var MIN_DISPATCH_INTERVAL_MS = 500; +var CURRENTLY_ACTIVE_THRESHOLD_MS = 500; /** * This class watches for user activity (moving the mouse or pressing a key) @@ -31,8 +32,14 @@ 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; + this.activityEndTimer = undefined; } /** @@ -41,10 +48,19 @@ class UserActivity { stop() { document.onmousemove = undefined; document.onkeypress = undefined; + window.removeEventListener('wheel', this._onUserActivity.bind(this), true); + } + + /** + * Return true if there has been user activity very recently + * (ie. within a few seconds) + */ + userCurrentlyActive() { + return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS; } _onUserActivity(event) { - if (event.screenX) { + if (event.screenX && event.type == "mousemove") { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { @@ -55,12 +71,32 @@ class UserActivity { this.lastScreenY = event.screenY; } - this.lastActivityAtTs = (new Date).getTime(); - if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL) { + this.lastActivityAtTs = new Date().getTime(); + if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) { this.lastDispatchAtTs = this.lastActivityAtTs; dis.dispatch({ action: 'user_activity' }); + if (!this.activityEndTimer) { + this.activityEndTimer = setTimeout( + this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS + ); + } + } + } + + _onActivityEndTimer() { + var now = new Date().getTime(); + var targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS; + if (now >= targetTime) { + dis.dispatch({ + action: 'user_activity_end' + }); + this.activityEndTimer = undefined; + } else { + this.activityEndTimer = setTimeout( + this._onActivityEndTimer.bind(this), targetTime - now + ); } } } diff --git a/src/component-index.js b/src/component-index.js index bfe1beca71..5a395d5696 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -31,6 +31,11 @@ module.exports.components['structures.RoomView'] = require('./components/structu module.exports.components['structures.ScrollPanel'] = require('./components/structures/ScrollPanel'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); +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'); +module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar'); module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton'); @@ -41,6 +46,7 @@ module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/ module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); +module.exports.components['views.elements.PowerSelector'] = require('./components/views/elements/PowerSelector'); module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar'); module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg'); module.exports.components['views.elements.UserSelector'] = require('./components/views/elements/UserSelector'); @@ -64,8 +70,10 @@ module.exports.components['views.rooms.MemberInfo'] = require('./components/view module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList'); module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile'); module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer'); +module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel'); module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader'); module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); +module.exports.components['views.rooms.RoomPreviewBar'] = require('./components/views/rooms/RoomPreviewBar'); module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings'); module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile'); diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js index c21bc80c6b..116202d324 100644 --- a/src/components/structures/CreateRoom.js +++ b/src/components/structures/CreateRoom.js @@ -251,7 +251,7 @@ module.exports = React.createClass({ var UserSelector = sdk.getComponent("elements.UserSelector"); var RoomHeader = sdk.getComponent("rooms.RoomHeader"); - var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); + var domain = MatrixClientPeg.get().getDomain(); return (
diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 320dad09b3..462933cbc6 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"); @@ -63,7 +64,7 @@ module.exports = React.createClass({ collapse_lhs: false, collapse_rhs: false, ready: false, - width: 10000 + width: 10000, }; if (s.logged_in) { if (MatrixClientPeg.get().getRooms().length) { @@ -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; @@ -296,7 +304,7 @@ module.exports = React.createClass({ }); break; case 'view_room': - this._viewRoom(payload.room_id); + this._viewRoom(payload.room_id, payload.show_settings); break; case 'view_prev_room': roomIndexDelta = -1; @@ -349,8 +357,29 @@ module.exports = React.createClass({ this.notifyNewScreen('settings'); break; case 'view_create_room': - this._setPage(this.PageTypes.CreateRoom); - this.notifyNewScreen('new'); + //this._setPage(this.PageTypes.CreateRoom); + //this.notifyNewScreen('new'); + + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var Loader = sdk.getComponent("elements.Spinner"); + var modal = Modal.createDialog(Loader); + + MatrixClientPeg.get().createRoom({ + preset: "private_chat" + }).done(function(res) { + modal.close(); + dis.dispatch({ + action: 'view_room', + room_id: res.room_id, + show_settings: true, + }); + }, function(err) { + modal.close(); + Modal.createDialog(ErrorDialog, { + title: "Failed to create room", + description: err.toString() + }); + }); break; case 'view_room_directory': this._setPage(this.PageTypes.RoomDirectory); @@ -391,7 +420,7 @@ module.exports = React.createClass({ }); }, - _viewRoom: function(roomId) { + _viewRoom: function(roomId, showSettings) { // before we switch room, record the scroll state of the current room this._updateScrollMap(); @@ -411,7 +440,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; } @@ -420,6 +458,9 @@ module.exports = React.createClass({ var scrollState = this.scrollStateMap[roomId]; this.refs.roomView.restoreScrollState(scrollState); } + if (this.refs.roomView && showSettings) { + this.refs.roomView.showSettings(true); + } }, // update scrollStateMap according to the current scroll state of the @@ -505,7 +546,9 @@ module.exports = React.createClass({ UserActivity.start(); Presence.start(); cli.startClient({ - pendingEventOrdering: "end" + pendingEventOrdering: "end", + // deliberately huge limit for now to avoid hitting gappy /sync's until gappy /sync performance improves + initialSyncLimit: 250, }); }, @@ -559,6 +602,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', @@ -614,6 +662,8 @@ module.exports = React.createClass({ onUserClick: function(event, userId) { event.preventDefault(); + + /* var MemberInfo = sdk.getComponent('rooms.MemberInfo'); var member = new Matrix.RoomMember(null, userId); ContextualMenu.createMenu(MemberInfo, { @@ -621,6 +671,14 @@ module.exports = React.createClass({ right: window.innerWidth - event.pageX, top: event.pageY }); + */ + + var member = new Matrix.RoomMember(null, userId); + if (!member) { return; } + dis.dispatch({ + action: 'view_user', + member: member, + }); }, onLogoutClick: function(event) { @@ -668,6 +726,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 +768,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 +864,21 @@ module.exports = React.createClass({ onLoggedIn={this.onRegistered} onLoginClick={this.onLoginClick} /> ); + } else if (this.state.screen == 'forgot_password') { + return ( + + ); } else { return ( + 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 25d95fdc41..b333a18331 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -35,11 +35,15 @@ var sdk = require('../../index'); var CallHandler = require('../../CallHandler'); var TabComplete = require("../../TabComplete"); var MemberEntry = require("../../TabCompleteEntries").MemberEntry; +var CommandEntry = require("../../TabCompleteEntries").CommandEntry; var Resend = require("../../Resend"); +var SlashCommands = require("../../SlashCommands"); 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,6 +78,9 @@ module.exports = React.createClass({ syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, + guestsCanJoin: false, + readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null, + readMarkerGhostEventId: undefined } }, @@ -81,6 +88,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); @@ -88,8 +96,6 @@ module.exports = React.createClass({ // 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, @@ -106,15 +112,27 @@ module.exports = React.createClass({ // succeeds then great, show the preview (but we still may be able to /join!). if (!this.state.room) { console.log("Attempting to peek into room %s", this.props.roomId); - MatrixClientPeg.get().peekInRoom(this.props.roomId).done(function() { + MatrixClientPeg.get().peekInRoom(this.props.roomId).done(() => { // we don't need to do anything - JS SDK will emit Room events - // which will update the UI. + // which will update the UI. We *do* however need to know if we + // can join the room so we can fiddle with the UI appropriately. + var peekedRoom = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!peekedRoom) { + return; + } + var guestAccessEvent = peekedRoom.currentState.getStateEvents("m.room.guest_access", ""); + if (!guestAccessEvent) { + return; + } + if (guestAccessEvent.getContent().guest_access === "can_join") { + this.setState({ + guestsCanJoin: true + }); + } }, function(err) { console.error("Failed to peek into room: %s", err); }); } - - }, componentWillUnmount: function() { @@ -124,21 +142,22 @@ module.exports = React.createClass({ // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; - 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 + if (this.refs.roomView) { + // disconnect the D&D event listeners from the room view. This + // is really just for hygiene - we're 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); - messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); - messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); + var roomView = ReactDOM.findDOMNode(this.refs.roomView); + roomView.removeEventListener('drop', this.onDrop); + roomView.removeEventListener('dragover', this.onDragOver); + roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); + roomView.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.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); @@ -146,6 +165,8 @@ module.exports = React.createClass({ } window.removeEventListener('resize', this.onResize); + + Tinter.tint(); // reset colourscheme }, onAction: function(payload) { @@ -199,6 +220,12 @@ module.exports = React.createClass({ break; case 'user_activity': + case 'user_activity_end': + // we could treat user_activity_end differently and not + // send receipts for messages that have arrived between + // the actual user activity and the time they stopped + // being active, but let's see if this is actually + // necessary. this.sendReadReceipt(); break; } @@ -259,9 +286,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, + }); } }, @@ -338,6 +414,14 @@ module.exports = React.createClass({ window.addEventListener('resize', this.onResize); this.onResize(); + if (this.refs.roomView) { + var roomView = ReactDOM.findDOMNode(this.refs.roomView); + roomView.addEventListener('drop', this.onDrop); + roomView.addEventListener('dragover', this.onDragOver); + roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); + roomView.addEventListener('dragend', this.onDragLeaveOrEnd); + } + this._updateTabCompleteList(this.state.room); }, @@ -346,7 +430,9 @@ module.exports = React.createClass({ return; } this.tabComplete.setCompletionList( - MemberEntry.fromMemberList(room.getJoinedMembers()) + MemberEntry.fromMemberList(room.getJoinedMembers()).concat( + CommandEntry.fromCommands(SlashCommands.getCommandList()) + ) ); }, @@ -354,13 +440,10 @@ module.exports = React.createClass({ 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.updateTint(); }, componentDidUpdate: function() { @@ -682,10 +765,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]; @@ -699,6 +782,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 = (
); + readMarkerIndex = ret.length; + ret.push(
  • {hr}
  • ); + } + // is this a continuation of the previous message? var continuation = false; if (prevEvent !== null) { @@ -735,13 +837,29 @@ module.exports = React.createClass({ ); - if (eventId == readReceiptEventId) { - ret.push(
    ); + // 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 = (
    ); + ret.splice(ghostIndex, 0, ( +
  • {hr}
  • + )); + } + return ret; }, @@ -769,9 +887,27 @@ module.exports = React.createClass({ old_history_visibility = "shared"; } + var old_guest_read = (old_history_visibility === "world_readable"); + + var old_guest_join = this.state.room.currentState.getStateEvents('m.room.guest_access', ''); + if (old_guest_join) { + old_guest_join = (old_guest_join.getContent().guest_access === "can_join"); + } + else { + old_guest_join = false; + } + + var old_canonical_alias = this.state.room.currentState.getStateEvents('m.room.canonical_alias', ''); + if (old_canonical_alias) { + old_canonical_alias = old_canonical_alias.getContent().alias; + } + else { + old_canonical_alias = ""; + } + var deferreds = []; - if (old_name != newVals.name && newVals.name != undefined && newVals.name) { + if (old_name != newVals.name && newVals.name != undefined) { deferreds.push( MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name) ); @@ -819,26 +955,128 @@ module.exports = React.createClass({ ); } - deferreds.push( - MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { - allowRead: newVals.guest_read, - allowJoin: newVals.guest_join - }) - ); + if (newVals.alias_operations) { + var oplist = []; + for (var i = 0; i < newVals.alias_operations.length; i++) { + var alias_operation = newVals.alias_operations[i]; + switch (alias_operation.type) { + case 'put': + oplist.push( + MatrixClientPeg.get().createAlias( + alias_operation.alias, this.state.room.roomId + ) + ); + break; + case 'delete': + oplist.push( + MatrixClientPeg.get().deleteAlias( + alias_operation.alias + ) + ); + break; + default: + console.log("Unknown alias operation, ignoring: " + alias_operation.type); + } + } + + if (oplist.length) { + var deferred = oplist[0]; + oplist.splice(1).forEach(function (f) { + deferred = deferred.then(f); + }); + deferreds.push(deferred); + } + } + + if (newVals.tag_operations) { + // FIXME: should probably be factored out with alias_operations above + var oplist = []; + for (var i = 0; i < newVals.tag_operations.length; i++) { + var tag_operation = newVals.tag_operations[i]; + switch (tag_operation.type) { + case 'put': + oplist.push( + MatrixClientPeg.get().setRoomTag( + this.props.roomId, tag_operation.tag, {} + ) + ); + break; + case 'delete': + oplist.push( + MatrixClientPeg.get().deleteRoomTag( + this.props.roomId, tag_operation.tag + ) + ); + break; + default: + console.log("Unknown tag operation, ignoring: " + tag_operation.type); + } + } + + if (oplist.length) { + var deferred = oplist[0]; + oplist.splice(1).forEach(function (f) { + deferred = deferred.then(f); + }); + deferreds.push(deferred); + } + } + + if (old_canonical_alias !== newVals.canonical_alias) { + deferreds.push( + MatrixClientPeg.get().sendStateEvent( + this.state.room.roomId, "m.room.canonical_alias", { + alias: newVals.canonical_alias + }, "" + ) + ); + } + + if (newVals.color_scheme) { + deferreds.push( + MatrixClientPeg.get().setRoomAccountData( + this.state.room.roomId, "org.matrix.room.color_scheme", newVals.color_scheme + ) + ); + } + + if (old_guest_read != newVals.guest_read || + old_guest_join != newVals.guest_join) + { + deferreds.push( + MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { + allowRead: newVals.guest_read, + allowJoin: newVals.guest_join + }) + ); + } if (deferreds.length) { var self = this; - q.all(deferreds).fail(function(err) { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Failed to set state", - description: err.toString() + q.allSettled(deferreds).then( + function(results) { + var fails = results.filter(function(result) { return result.state !== "fulfilled" }); + if (fails.length) { + fails.forEach(function(result) { + console.error(result.reason); + }); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to set state", + description: fails.map(function(result) { return result.reason }).join("\n"), + }); + self.refs.room_settings.resetState(); + } + else { + self.setState({ + editingRoomSettings: false + }); + } + }).finally(function() { + self.setState({ + uploadingRoomSettings: false, + }); }); - }).finally(function() { - self.setState({ - uploadingRoomSettings: false, - }); - }); } else { this.setState({ editingRoomSettings: false, @@ -906,23 +1144,27 @@ module.exports = React.createClass({ onSaveClick: function() { this.setState({ - editingRoomSettings: false, uploadingRoomSettings: true, }); this.uploadNewState({ name: this.refs.header.getRoomName(), - topic: this.refs.room_settings.getTopic(), + topic: this.refs.header.getTopic(), join_rule: this.refs.room_settings.getJoinRules(), history_visibility: this.refs.room_settings.getHistoryVisibility(), are_notifications_muted: this.refs.room_settings.areNotificationsMuted(), power_levels: this.refs.room_settings.getPowerLevels(), + alias_operations: this.refs.room_settings.getAliasOperations(), + tag_operations: this.refs.room_settings.getTagOperations(), + canonical_alias: this.refs.room_settings.getCanonicalAlias(), 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}); }, @@ -1070,26 +1312,32 @@ module.exports = React.createClass({ // 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; + + // header + footer + status + give us at least 120px of scrollback at all times. + var auxPanelMaxHeight = window.innerHeight - + (83 + // height of RoomHeader + 36 + // height of the status area + 72 + // minimum height of the message compmoser + 120); // amount of desired scrollback + + // 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; + if (this.refs.callView) { var video = this.refs.callView.getVideoView().getRemoteVideoElement(); - // header + footer + status + give us at least 100px of scrollback at all times. - auxPanelMaxHeight = window.innerHeight - - (83 + 72 + - sdk.getComponent('rooms.MessageComposer').MAX_HEIGHT + - 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"; // the above might have made the video panel resize itself, so now // we need to tell the gemini panel to adapt. this.onChildResize(); } + + // we need to do this for general auxPanels too + if (this.refs.auxPanel) { + this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px"; + } }, onFullscreenClick: function() { @@ -1132,6 +1380,13 @@ module.exports = React.createClass({ } }, + showSettings: function(show) { + // XXX: this is a bit naughty; we should be doing this via props + if (show) { + this.setState({editingRoomSettings: true}); + } + }, + render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1140,6 +1395,7 @@ module.exports = React.createClass({ var SearchBar = sdk.getComponent("rooms.SearchBar"); var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); var TintableSvg = sdk.getComponent("elements.TintableSvg"); + var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); if (!this.state.room) { if (this.props.roomId) { @@ -1281,7 +1537,7 @@ module.exports = React.createClass({ var aux = null; if (this.state.editingRoomSettings) { - aux = ; + aux = ; } else if (this.state.uploadingRoomSettings) { var Loader = sdk.getComponent("elements.Spinner"); @@ -1290,6 +1546,12 @@ module.exports = React.createClass({ else if (this.state.searching) { aux = ; } + else if (this.state.guestsCanJoin && MatrixClientPeg.get().isGuest() && + (!myMember || myMember.membership !== "join")) { + aux = ( + + ); + } var conferenceCallNotification = null; if (this.state.displayConfCallNotification) { @@ -1309,7 +1571,7 @@ module.exports = React.createClass({ fileDropTarget =

    - Drop File Here + Drop file here to upload
    ; } @@ -1410,7 +1672,7 @@ module.exports = React.createClass({ ); return ( -
    +
    - { fileDropTarget } -
    +
    + { fileDropTarget } { conferenceCallNotification } diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 12e502026f..eda843eb8a 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -57,7 +57,7 @@ module.exports = React.createClass({displayName: 'UploadBar', } } if (!upload) { - upload = uploads[0]; + return
    } var innerProgressStyle = { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 9196782070..44b7b9a973 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -21,6 +21,7 @@ var dis = require("../../dispatcher"); var q = require('q'); var version = require('../../../package.json').version; var UserSettingsStore = require('../../UserSettingsStore'); +var GeminiScrollbar = require('react-gemini-scrollbar'); module.exports = React.createClass({ displayName: 'UserSettings', @@ -83,6 +84,12 @@ module.exports = React.createClass({ } }, + onAvatarPickerClick: function(ev) { + if (this.refs.file_label) { + this.refs.file_label.click(); + } + }, + onAvatarSelected: function(ev) { var self = this; var changeAvatar = this.refs.changeAvatar; @@ -172,7 +179,7 @@ module.exports = React.createClass({ if (MatrixClientPeg.get().isGuest()) { accountJsx = (
    - Upgrade (It's free!) + Create an account
    ); } @@ -193,6 +200,8 @@ module.exports = React.createClass({
    + +

    Profile

    @@ -222,13 +231,15 @@ module.exports = React.createClass({
    - +
    + +
    -
    @@ -238,13 +249,12 @@ module.exports = React.createClass({

    Account

    - {accountJsx} -
    - -
    -
    + +
    Log out
    + + {accountJsx}

    Notifications

    @@ -263,6 +273,8 @@ module.exports = React.createClass({ Version {this.state.clientVersion}
    + +
    ); } diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js new file mode 100644 index 0000000000..dcf6a7c28e --- /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 = + } + else if (this.state.progress === "sent_email") { + resetPasswordJsx = ( +
    + An email has been sent to {this.state.email}. Once you've followed + the link it contains, click below. +
    + +
    + ); + } + else if (this.state.progress === "complete") { + resetPasswordJsx = ( +
    +

    Your password has been reset.

    +

    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.

    + +
    + ); + } + else { + resetPasswordJsx = ( +
    + To reset your password, enter the email address linked to your account: +
    +
    +
    + +
    + +
    + +
    + +
    + + +
    +
    + ); + } + + + return ( +
    +
    + + {resetPasswordJsx} +
    +
    + ); + } +}); diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index b7d2d762a4..b853b8fd95 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 ( - + ); case 'm.login.cas': return ( diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index f89d65d740..7b2808c72a 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -159,6 +159,15 @@ module.exports = React.createClass({ case "RegistrationForm.ERR_PASSWORD_LENGTH": errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`; break; + case "RegistrationForm.ERR_EMAIL_INVALID": + errMsg = "This doesn't look like a valid email address"; + break; + case "RegistrationForm.ERR_USERNAME_INVALID": + errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores."; + break; + case "RegistrationForm.ERR_USERNAME_BLANK": + errMsg = "You need to enter a user name"; + break; default: console.error("Unknown error code: %s", errCode); errMsg = "An unknown error occurred."; diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js new file mode 100644 index 0000000000..2a7dcc1c01 --- /dev/null +++ b/src/components/views/avatars/BaseAvatar.js @@ -0,0 +1,140 @@ +/* +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 AvatarLogic = require("../../../Avatar"); + +module.exports = React.createClass({ + displayName: 'BaseAvatar', + + propTypes: { + name: React.PropTypes.string.isRequired, // The name (first initial used as default) + idName: React.PropTypes.string, // ID for generating hash colours + title: React.PropTypes.string, // onHover title text + url: React.PropTypes.string, // highest priority of them all, shortcut to set in urls[0] + urls: React.PropTypes.array, // [highest_priority, ... , lowest_priority] + width: React.PropTypes.number, + height: React.PropTypes.number, + resizeMethod: React.PropTypes.string, + defaultToInitialLetter: React.PropTypes.bool // true to add default url + }, + + getDefaultProps: function() { + return { + width: 40, + height: 40, + resizeMethod: 'crop', + defaultToInitialLetter: true + } + }, + + getInitialState: function() { + return this._getState(this.props); + }, + + componentWillReceiveProps: function(nextProps) { + // work out if we need to call setState (if the image URLs array has changed) + var newState = this._getState(nextProps); + var newImageUrls = newState.imageUrls; + var oldImageUrls = this.state.imageUrls; + if (newImageUrls.length !== oldImageUrls.length) { + this.setState(newState); // detected a new entry + } + else { + // check each one to see if they are the same + for (var i = 0; i < newImageUrls.length; i++) { + if (oldImageUrls[i] !== newImageUrls[i]) { + this.setState(newState); // detected a diff + break; + } + } + } + }, + + _getState: function(props) { + // work out the full set of urls to try to load. This is formed like so: + // imageUrls: [ props.url, props.urls, default image ] + + var urls = props.urls || []; + if (props.url) { + urls.unshift(props.url); // put in urls[0] + } + + var defaultImageUrl = null; + if (props.defaultToInitialLetter) { + defaultImageUrl = AvatarLogic.defaultAvatarUrlForString( + props.idName || props.name + ); + urls.push(defaultImageUrl); // lowest priority + } + return { + imageUrls: urls, + defaultImageUrl: defaultImageUrl, + urlsIndex: 0 + }; + }, + + onError: function(ev) { + var nextIndex = this.state.urlsIndex + 1; + if (nextIndex < this.state.imageUrls.length) { + // try the next one + this.setState({ + urlsIndex: nextIndex + }); + } + }, + + _getInitialLetter: function() { + var name = this.props.name; + var initial = name[0]; + if ((initial === '@' || initial === '#') && name[1]) { + initial = name[1]; + } + return initial.toUpperCase(); + }, + + render: function() { + var name = this.props.name; + + var imageUrl = this.state.imageUrls[this.state.urlsIndex]; + + if (imageUrl === this.state.defaultImageUrl) { + var initialLetter = this._getInitialLetter(); + return ( + + + + + ); + } + return ( + + ); + } +}); diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 21c717aac5..5e2dbbb23a 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -18,7 +18,7 @@ limitations under the License. var React = require('react'); var Avatar = require('../../../Avatar'); -var MatrixClientPeg = require('../../../MatrixClientPeg'); +var sdk = require("../../../index"); module.exports = React.createClass({ displayName: 'MemberAvatar', @@ -27,7 +27,7 @@ module.exports = React.createClass({ member: React.PropTypes.object.isRequired, width: React.PropTypes.number, height: React.PropTypes.number, - resizeMethod: React.PropTypes.string, + resizeMethod: React.PropTypes.string }, getDefaultProps: function() { @@ -38,75 +38,30 @@ module.exports = React.createClass({ } }, - componentWillReceiveProps: function(nextProps) { - this.refreshUrl(); - }, - - defaultAvatarUrl: function(member, width, height, resizeMethod) { - return Avatar.defaultAvatarUrlForString(member.userId); - }, - - onError: function(ev) { - // don't tightloop if the browser can't load a data url - if (ev.target.src == this.defaultAvatarUrl(this.props.member)) { - return; - } - this.setState({ - imageUrl: this.defaultAvatarUrl(this.props.member) - }); - }, - - _computeUrl: function() { - return Avatar.avatarUrlForMember(this.props.member, - this.props.width, - this.props.height, - this.props.resizeMethod); - }, - - refreshUrl: function() { - var newUrl = this._computeUrl(); - if (newUrl != this.currentUrl) { - this.currentUrl = newUrl; - this.setState({imageUrl: newUrl}); - } - }, - getInitialState: function() { - return { - imageUrl: this._computeUrl() - }; + return this._getState(this.props); }, + componentWillReceiveProps: function(nextProps) { + this.setState(this._getState(nextProps)); + }, - /////////////// + _getState: function(props) { + return { + name: props.member.name, + title: props.member.userId, + imageUrl: Avatar.avatarUrlForMember(props.member, + props.width, + props.height, + props.resizeMethod) + } + }, render: function() { - // XXX: recalculates default avatar url constantly - if (this.state.imageUrl === this.defaultAvatarUrl(this.props.member)) { - var initial; - if (this.props.member.name[0]) - initial = this.props.member.name[0].toUpperCase(); - if (initial === '@' && this.props.member.name[1]) - initial = this.props.member.name[1].toUpperCase(); - - return ( - - - - - ); - } + var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); return ( - + ); } }); diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index a83bc799a2..72ca5f7f21 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -16,10 +16,18 @@ limitations under the License. var React = require('react'); var MatrixClientPeg = require('../../../MatrixClientPeg'); var Avatar = require('../../../Avatar'); +var sdk = require("../../../index"); module.exports = React.createClass({ displayName: 'RoomAvatar', + propTypes: { + room: React.PropTypes.object.isRequired, + width: React.PropTypes.number, + height: React.PropTypes.number, + resizeMethod: React.PropTypes.string + }, + getDefaultProps: function() { return { width: 36, @@ -29,84 +37,54 @@ module.exports = React.createClass({ }, getInitialState: function() { - this._update(); return { - imageUrl: this._nextUrl() + urls: this.getImageUrls(this.props) }; }, - componentWillReceiveProps: function(nextProps) { - this.refreshImageUrl(); + componentWillReceiveProps: function(newProps) { + this.setState({ + urls: this.getImageUrls(newProps) + }) }, - refreshImageUrl: function(nextProps) { - // If the list has changed, we start from scratch and re-check, but - // don't do so unless the list has changed or we'd re-try fetching - // images each time we re-rendered - var newList = this.getUrlList(); - var differs = false; - for (var i = 0; i < newList.length && i < this.urlList.length; ++i) { - if (this.urlList[i] != newList[i]) differs = true; - } - if (this.urlList.length != newList.length) differs = true; - - if (differs) { - this._update(); - this.setState({ - imageUrl: this._nextUrl() - }); - } + getImageUrls: function(props) { + return [ + this.getRoomAvatarUrl(props), // highest priority + this.getOneToOneAvatar(props), + this.getFallbackAvatar(props) // lowest priority + ].filter(function(url) { + return url != null; + }); }, - _update: function() { - this.urlList = this.getUrlList(); - this.urlListIndex = -1; - }, - - _nextUrl: function() { - do { - ++this.urlListIndex; - } while ( - this.urlList[this.urlListIndex] === null && - this.urlListIndex < this.urlList.length - ); - if (this.urlListIndex < this.urlList.length) { - return this.urlList[this.urlListIndex]; - } else { - return null; - } - }, - - // provided to the view class for convenience - roomAvatarUrl: function() { - var url = this.props.room.getAvatarUrl( + getRoomAvatarUrl: function(props) { + return props.room.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), - this.props.width, this.props.height, this.props.resizeMethod, + props.width, props.height, props.resizeMethod, false ); - return url; }, - // provided to the view class for convenience - getOneToOneAvatar: function() { - var userIds = Object.keys(this.props.room.currentState.members); + getOneToOneAvatar: function(props) { + var userIds = Object.keys(props.room.currentState.members); if (userIds.length == 2) { var theOtherGuy = null; - if (this.props.room.currentState.members[userIds[0]].userId == MatrixClientPeg.get().credentials.userId) { - theOtherGuy = this.props.room.currentState.members[userIds[1]]; + if (props.room.currentState.members[userIds[0]].userId == MatrixClientPeg.get().credentials.userId) { + theOtherGuy = props.room.currentState.members[userIds[1]]; } else { - theOtherGuy = this.props.room.currentState.members[userIds[0]]; + theOtherGuy = props.room.currentState.members[userIds[0]]; } return theOtherGuy.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), - this.props.width, this.props.height, this.props.resizeMethod, + props.width, props.height, props.resizeMethod, false ); } else if (userIds.length == 1) { - return this.props.room.currentState.members[userIds[0]].getAvatarUrl( + return props.room.currentState.members[userIds[0]].getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), - this.props.width, this.props.height, this.props.resizeMethod, + props.width, props.height, props.resizeMethod, false ); } else { @@ -114,58 +92,15 @@ module.exports = React.createClass({ } }, - - onError: function(ev) { - this.setState({ - imageUrl: this._nextUrl() - }); - }, - - - - //////////// - - - getUrlList: function() { - return [ - this.roomAvatarUrl(), - this.getOneToOneAvatar(), - this.getFallbackAvatar() - ]; - }, - - getFallbackAvatar: function() { - return Avatar.defaultAvatarUrlForString(this.props.room.roomId); + getFallbackAvatar: function(props) { + return Avatar.defaultAvatarUrlForString(props.room.roomId); }, render: function() { - var style = { - width: this.props.width, - height: this.props.height, - }; - - // XXX: recalculates fallback avatar constantly - if (this.state.imageUrl === this.getFallbackAvatar()) { - var initial; - if (this.props.room.name[0]) - initial = this.props.room.name[0].toUpperCase(); - if ((initial === '@' || initial === '#') && this.props.room.name[1]) - initial = this.props.room.name[1].toUpperCase(); - - return ( - - - - - ); - } - else { - return - } + var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + return ( + + ); } }); diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index beedfc35c8..683cfe4fc8 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -18,13 +18,22 @@ limitations under the License. var React = require('react'); +const KEY_TAB = 9; +const KEY_SHIFT = 16; +const KEY_WINDOWS = 91; + module.exports = React.createClass({ displayName: 'EditableText', propTypes: { onValueChanged: React.PropTypes.func, initialValue: React.PropTypes.string, label: React.PropTypes.string, - placeHolder: React.PropTypes.string, + placeholder: React.PropTypes.string, + className: React.PropTypes.string, + labelClassName: React.PropTypes.string, + placeholderClassName: React.PropTypes.string, + blurToCancel: React.PropTypes.bool, + editable: React.PropTypes.bool, }, Phases: { @@ -36,38 +45,62 @@ module.exports = React.createClass({ return { onValueChanged: function() {}, initialValue: '', - label: 'Click to set', + label: '', placeholder: '', + editable: true, }; }, getInitialState: function() { return { - value: this.props.initialValue, phase: this.Phases.Display, } }, componentWillReceiveProps: function(nextProps) { - this.setState({ - value: nextProps.initialValue - }); + if (nextProps.initialValue !== this.props.initialValue) { + this.value = nextProps.initialValue; + if (this.refs.editable_div) { + this.showPlaceholder(!this.value); + } + } + }, + + componentWillMount: function() { + // we track value as an JS object field rather than in React state + // as React doesn't play nice with contentEditable. + this.value = ''; + this.placeholder = false; + }, + + componentDidMount: function() { + this.value = this.props.initialValue; + if (this.refs.editable_div) { + this.showPlaceholder(!this.value); + } + }, + + showPlaceholder: function(show) { + if (show) { + this.refs.editable_div.textContent = this.props.placeholder; + this.refs.editable_div.setAttribute("class", this.props.className + " " + this.props.placeholderClassName); + this.placeholder = true; + this.value = ''; + } + else { + this.refs.editable_div.textContent = this.value; + this.refs.editable_div.setAttribute("class", this.props.className); + this.placeholder = false; + } }, getValue: function() { - return this.state.value; + return this.value; }, - setValue: function(val, shouldSubmit, suppressListener) { - var self = this; - this.setState({ - value: val, - phase: this.Phases.Display, - }, function() { - if (!suppressListener) { - self.onValueChanged(shouldSubmit); - } - }); + setValue: function(value) { + this.value = value; + this.showPlaceholder(!this.value); }, edit: function() { @@ -80,65 +113,106 @@ module.exports = React.createClass({ this.setState({ phase: this.Phases.Display, }); + this.value = this.props.initialValue; + this.showPlaceholder(!this.value); this.onValueChanged(false); }, onValueChanged: function(shouldSubmit) { - this.props.onValueChanged(this.state.value, shouldSubmit); + this.props.onValueChanged(this.value, shouldSubmit); + }, + + onKeyDown: function(ev) { + // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); + + if (this.placeholder) { + this.showPlaceholder(false); + } + + if (ev.key == "Enter") { + ev.stopPropagation(); + ev.preventDefault(); + } + + // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); }, onKeyUp: function(ev) { + // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); + + if (!ev.target.textContent) { + this.showPlaceholder(true); + } + else if (!this.placeholder) { + this.value = ev.target.textContent; + } + if (ev.key == "Enter") { this.onFinish(ev); } else if (ev.key == "Escape") { this.cancelEdit(); } + + // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); }, - onClickDiv: function() { + onClickDiv: function(ev) { + if (!this.props.editable) return; + this.setState({ phase: this.Phases.Edit, }) }, onFocus: function(ev) { - ev.target.setSelectionRange(0, ev.target.value.length); - }, + //ev.target.setSelectionRange(0, ev.target.textContent.length); - onFinish: function(ev) { - if (ev.target.value) { - this.setValue(ev.target.value, ev.key === "Enter"); - } else { - this.cancelEdit(); + var node = ev.target.childNodes[0]; + if (node) { + var range = document.createRange(); + range.setStart(node, 0); + range.setEnd(node, node.length); + + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); } }, - onBlur: function() { - this.cancelEdit(); + onFinish: function(ev) { + var self = this; + var submit = (ev.key === "Enter"); + this.setState({ + phase: this.Phases.Display, + }, function() { + self.onValueChanged(submit); + }); + }, + + onBlur: function(ev) { + var sel = window.getSelection(); + sel.removeAllRanges(); + + if (this.props.blurToCancel) + this.cancelEdit(); + else + this.onFinish(ev); + + this.showPlaceholder(!this.value); }, render: function() { var editable_el; - if (this.state.phase == this.Phases.Display) { - if (this.state.value) { - editable_el =
    {this.state.value}
    ; - } else { - editable_el =
    {this.props.label}
    ; - } - } else if (this.state.phase == this.Phases.Edit) { - editable_el = ( -
    - -
    - ); + if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) { + // show the label + editable_el =
    { this.props.label || this.props.initialValue }
    ; + } else { + // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together + editable_el =
    ; } - return ( -
    - {editable_el} -
    - ); + return editable_el; } }); diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js new file mode 100644 index 0000000000..c47c9f3809 --- /dev/null +++ b/src/components/views/elements/PowerSelector.js @@ -0,0 +1,108 @@ +/* +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 roles = { + 0: 'User', + 50: 'Moderator', + 100: 'Admin', +}; + +var reverseRoles = {}; +Object.keys(roles).forEach(function(key) { + reverseRoles[roles[key]] = key; +}); + +module.exports = React.createClass({ + displayName: 'PowerSelector', + + propTypes: { + value: React.PropTypes.number.isRequired, + disabled: React.PropTypes.bool, + onChange: React.PropTypes.func, + }, + + getInitialState: function() { + return { + custom: (roles[this.props.value] === undefined), + }; + }, + + onSelectChange: function(event) { + this.state.custom = (event.target.value === "Custom"); + this.props.onChange(this.getValue()); + }, + + onCustomBlur: function(event) { + this.props.onChange(this.getValue()); + }, + + onCustomKeyDown: function(event) { + if (event.key == "Enter") { + this.props.onChange(this.getValue()); + } + }, + + getValue: function() { + var value; + if (this.refs.select) { + value = reverseRoles[ this.refs.select.value ]; + if (this.refs.custom) { + if (value === undefined) value = parseInt( this.refs.custom.value ); + } + } + return value; + }, + + render: function() { + var customPicker; + if (this.state.custom) { + var input; + if (this.props.disabled) { + input = { this.props.value } + } + else { + input = + } + customPicker = of { input }; + } + + var selectValue = roles[this.props.value] || "Custom"; + var select; + if (this.props.disabled) { + select = { selectValue }; + } + else { + select = + + } + + return ( + + { select } + { customPicker } + + ); + } +}); diff --git a/src/components/views/login/PasswordLogin.js b/src/components/views/login/PasswordLogin.js index 3367ac3257..a8751da1a7 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 = ( + + Forgot your password? + + ); + } + return (
    @@ -57,6 +68,7 @@ module.exports = React.createClass({displayName: 'PasswordLogin', value={this.state.password} onChange={this.onPasswordChanged} placeholder="Password" />
    + {forgotPasswordJsx}
    diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 534464a4ae..58d7ca3aab 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -17,8 +17,15 @@ limitations under the License. 'use strict'; var React = require('react'); +var Velocity = require('velocity-animate'); +require('velocity-ui-pack'); var sdk = require('../../../index'); +var FIELD_EMAIL = 'field_email'; +var FIELD_USERNAME = 'field_username'; +var FIELD_PASSWORD = 'field_password'; +var FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; + /** * A pure UI component which displays a registration form. */ @@ -50,52 +57,151 @@ module.exports = React.createClass({ email: this.props.defaultEmail, username: this.props.defaultUsername, password: null, - passwordConfirm: null + passwordConfirm: null, + fieldValid: {} }; }, onSubmit: function(ev) { ev.preventDefault(); - var pwd1 = this.refs.password.value.trim(); - var pwd2 = this.refs.passwordConfirm.value.trim() + // validate everything, in reverse order so + // the error that ends up being displayed + // is the one from the first invalid field. + // It's not super ideal that this just calls + // onError once for each invalid field. + this.validateField(FIELD_PASSWORD_CONFIRM); + this.validateField(FIELD_PASSWORD); + this.validateField(FIELD_USERNAME); + this.validateField(FIELD_EMAIL); - 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; + if (this.allFieldsValid()) { + var promise = this.props.onRegisterClick({ + username: this.refs.username.value.trim(), + password: this.refs.password.value.trim(), + email: this.refs.email.value.trim() }); + + if (promise) { + ev.target.disabled = true; + promise.finally(function() { + ev.target.disabled = false; + }); + } } }, + /** + * Returns true if all fields were valid last time + * they were validated. + */ + allFieldsValid: function() { + var keys = Object.keys(this.state.fieldValid); + for (var i = 0; i < keys.length; ++i) { + if (this.state.fieldValid[keys[i]] == false) { + return false; + } + } + return true; + }, + + validateField: function(field_id) { + var pwd1 = this.refs.password.value.trim(); + var pwd2 = this.refs.passwordConfirm.value.trim() + + switch (field_id) { + case FIELD_EMAIL: + this.markFieldValid( + field_id, + this.refs.email.value == '' || !!this.refs.email.value.match(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i), + "RegistrationForm.ERR_EMAIL_INVALID" + ); + break; + case FIELD_USERNAME: + // XXX: SPEC-1 + if (encodeURIComponent(this.refs.username.value) != this.refs.username.value) { + this.markFieldValid( + field_id, + false, + "RegistrationForm.ERR_USERNAME_INVALID" + ); + } else if (this.refs.username.value == '') { + this.markFieldValid( + field_id, + false, + "RegistrationForm.ERR_USERNAME_BLANK" + ); + } else { + this.markFieldValid(field_id, true); + } + break; + case FIELD_PASSWORD: + if (pwd1 == '') { + this.markFieldValid( + field_id, + false, + "RegistrationForm.ERR_PASSWORD_MISSING" + ); + } else if (pwd1.length < this.props.minPasswordLength) { + this.markFieldValid( + field_id, + false, + "RegistrationForm.ERR_PASSWORD_LENGTH" + ); + } else { + this.markFieldValid(field_id, true); + } + break; + case FIELD_PASSWORD_CONFIRM: + this.markFieldValid( + field_id, pwd1 == pwd2, + "RegistrationForm.ERR_PASSWORD_MISMATCH" + ); + break; + } + }, + + markFieldValid: function(field_id, val, error_code) { + var fieldValid = this.state.fieldValid; + fieldValid[field_id] = val; + this.setState({fieldValid: fieldValid}); + if (!val) { + Velocity(this.fieldElementById(field_id), "callout.shake", 300); + this.props.onError(error_code); + } + }, + + fieldElementById(field_id) { + switch (field_id) { + case FIELD_EMAIL: + return this.refs.email; + case FIELD_USERNAME: + return this.refs.username; + case FIELD_PASSWORD: + return this.refs.password; + case FIELD_PASSWORD_CONFIRM: + return this.refs.passwordConfirm; + } + }, + + _styleField: function(field_id, baseStyle) { + var style = baseStyle || {}; + if (this.state.fieldValid[field_id] === false) { + style['borderColor'] = 'red'; + } + return style; + }, + render: function() { + var self = this; var emailSection, registerButton; if (this.props.showEmail) { emailSection = ( + defaultValue={this.state.email} + style={this._styleField(FIELD_EMAIL)} + onBlur={function() {self.validateField(FIELD_EMAIL)}} /> ); } if (this.props.onRegisterClick) { @@ -111,13 +217,19 @@ module.exports = React.createClass({



    {registerButton} diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index fe763d06bf..e3613ef9a3 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -36,6 +36,9 @@ module.exports = React.createClass({ }, componentDidUpdate: function() { + // XXX: why don't we linkify here? + // XXX: why do we bother doing this on update at all, given events are immutable? + if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); }, diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index b5f0b88b40..a8a601c2d6 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -58,15 +58,16 @@ module.exports = React.createClass({ var roomId = this.props.member.roomId; var target = this.props.member.userId; MatrixClientPeg.get().kick(roomId, target).done(function() { - // NO-OP; rely on the m.room.member event coming down else we could - // get out of sync if we force setState here! - console.log("Kick success"); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Kick error", - description: err.message - }); - }); + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Kick success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Kick error", + description: err.message + }); + } + ); this.props.onFinished(); }, @@ -74,16 +75,18 @@ module.exports = React.createClass({ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var roomId = this.props.member.roomId; var target = this.props.member.userId; - MatrixClientPeg.get().ban(roomId, target).done(function() { - // NO-OP; rely on the m.room.member event coming down else we could - // get out of sync if we force setState here! - console.log("Ban success"); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Ban error", - description: err.message - }); - }); + MatrixClientPeg.get().ban(roomId, target).done( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Ban success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Ban error", + description: err.message + }); + } + ); this.props.onFinished(); }, @@ -118,16 +121,17 @@ module.exports = React.createClass({ } MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done( - function() { - // NO-OP; rely on the m.room.member event coming down else we could - // get out of sync if we force setState here! - console.log("Mute toggle success"); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Mute error", - description: err.message - }); - }); + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Mute toggle success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Mute error", + description: err.message + }); + } + ); this.props.onFinished(); }, @@ -154,22 +158,55 @@ module.exports = React.createClass({ } var defaultLevel = powerLevelEvent.getContent().users_default; var modLevel = me.powerLevel - 1; + if (modLevel > 50 && defaultLevel < 50) modLevel = 50; // try to stick with the vector level defaults // toggle the level var newLevel = this.state.isTargetMod ? defaultLevel : modLevel; MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done( - function() { - // NO-OP; rely on the m.room.member event coming down else we could - // get out of sync if we force setState here! - console.log("Mod toggle success"); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Mod error", - description: err.message - }); - }); + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Mod toggle success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Mod error", + description: err.message + }); + } + ); this.props.onFinished(); }, + onPowerChange: function(powerLevel) { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var roomId = this.props.member.roomId; + var target = this.props.member.userId; + var room = MatrixClientPeg.get().getRoom(roomId); + if (!room) { + this.props.onFinished(); + return; + } + var powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + if (!powerLevelEvent) { + this.props.onFinished(); + return; + } + MatrixClientPeg.get().setPowerLevel(roomId, target, powerLevel, powerLevelEvent).done( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Power change success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Failure to change power level", + description: err.message + }); + } + ); + this.props.onFinished(); + }, + onChatClick: function() { // check if there are any existing rooms with just us and them (1:1) // If so, just view that room. If not, create a private room with them. @@ -209,20 +246,22 @@ module.exports = React.createClass({ MatrixClientPeg.get().createRoom({ invite: [this.props.member.userId], preset: "private_chat" - }).done(function(res) { - self.setState({ creatingRoom: false }); - dis.dispatch({ - action: 'view_room', - room_id: res.room_id - }); - self.props.onFinished(); - }, function(err) { - self.setState({ creatingRoom: false }); - console.error( - "Failed to create room: %s", JSON.stringify(err) - ); - self.props.onFinished(); - }); + }).done( + function(res) { + self.setState({ creatingRoom: false }); + dis.dispatch({ + action: 'view_room', + room_id: res.room_id + }); + self.props.onFinished(); + }, function(err) { + self.setState({ creatingRoom: false }); + console.error( + "Failed to create room: %s", JSON.stringify(err) + ); + self.props.onFinished(); + } + ); } }, @@ -291,9 +330,15 @@ module.exports = React.createClass({ (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default ); + var levelToSend = ( + (powerLevels.events ? powerLevels.events["m.room.message"] : null) || + powerLevels.events_default + ); + can.kick = me.powerLevel >= powerLevels.kick; can.ban = me.powerLevel >= powerLevels.ban; can.mute = me.powerLevel >= editPowerLevel; + can.toggleMod = me.powerLevel > them.powerLevel && them.powerLevel >= levelToSend; can.modifyLevel = me.powerLevel > them.powerLevel; return can; }, @@ -317,12 +362,11 @@ module.exports = React.createClass({ }, render: function() { - var interactButton, kickButton, banButton, muteButton, giveModButton, spinner; - if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) { - interactButton =
    Leave room
    ; - } - else { - interactButton =
    Start chat
    ; + var startChat, kickButton, banButton, muteButton, giveModButton, spinner; + if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { + // FIXME: we're referring to a vector component from react-sdk + var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile'); + startChat = } if (this.state.creatingRoom) { @@ -346,35 +390,56 @@ module.exports = React.createClass({ {muteLabel}
    ; } - if (this.state.can.modifyLevel) { - var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod"; + if (this.state.can.toggleMod) { + var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator"; giveModButton =
    {giveOpLabel}
    } + // TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet + // e.g. clicking on a linkified userid in a room + + var adminTools; + if (kickButton || banButton || muteButton || giveModButton) { + adminTools = +
    +

    Admin tools

    + +
    + {muteButton} + {kickButton} + {banButton} + {giveModButton} +
    +
    + } + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var PowerSelector = sdk.getComponent('elements.PowerSelector'); return (
    +

    { this.props.member.name }

    -
    - { this.props.member.userId } -
    -
    - power: { this.props.member.powerLevelNorm }% -
    -
    - {interactButton} - {muteButton} - {kickButton} - {banButton} - {giveModButton} - {spinner} + +
    +
    + { this.props.member.userId } +
    +
    + Level: +
    + + { startChat } + + { adminTools } + + { spinner }
    ); } diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index b0e2baa3d3..3e3221992e 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -15,12 +15,19 @@ limitations under the License. */ var React = require('react'); var classNames = require('classnames'); +var Matrix = require("matrix-js-sdk"); +var q = require('q'); var MatrixClientPeg = require("../../../MatrixClientPeg"); var Modal = require("../../../Modal"); var sdk = require('../../../index'); var GeminiScrollbar = require('react-gemini-scrollbar'); var INITIAL_LOAD_NUM_MEMBERS = 50; +var SHARE_HISTORY_WARNING = "Newly invited users will see the history of this room. "+ + "If you'd prefer invited users not to see messages that were sent before they joined, "+ + "turn off, 'Share message history with new users' in the settings for this room."; + +var shown_invite_warning_this_session = false; module.exports = React.createClass({ displayName: 'MemberList', @@ -131,12 +138,41 @@ module.exports = React.createClass({ return; } - var promise; + var invite_defer = q.defer(); + + var room = MatrixClientPeg.get().getRoom(this.props.roomId); + var history_visibility = room.currentState.getStateEvents('m.room.history_visibility', ''); + if (history_visibility) history_visibility = history_visibility.getContent().history_visibility; + + if (history_visibility == 'shared' && !shown_invite_warning_this_session) { + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Warning", + description: SHARE_HISTORY_WARNING, + button: "Invite", + onFinished: function(should_invite) { + if (should_invite) { + shown_invite_warning_this_session = true; + invite_defer.resolve(); + } else { + invite_defer.reject(null); + } + } + }); + } else { + invite_defer.resolve(); + } + + var promise = invite_defer.promise;; if (isEmailAddress) { - promise = MatrixClientPeg.get().inviteByEmail(this.props.roomId, inputText); + promise = promise.then(function() { + MatrixClientPeg.get().inviteByEmail(self.props.roomId, inputText); + }); } else { - promise = MatrixClientPeg.get().invite(this.props.roomId, inputText); + promise = promise.then(function() { + MatrixClientPeg.get().invite(self.props.roomId, inputText); + }); } self.setState({ @@ -151,11 +187,13 @@ module.exports = React.createClass({ inviting: false }); }, function(err) { - console.error("Failed to invite: %s", JSON.stringify(err)); - Modal.createDialog(ErrorDialog, { - title: "Server error whilst inviting", - description: err.message - }); + if (err !== null) { + console.error("Failed to invite: %s", JSON.stringify(err)); + Modal.createDialog(ErrorDialog, { + title: "Server error whilst inviting", + description: err.message + }); + } self.setState({ inviting: false }); @@ -229,7 +267,8 @@ module.exports = React.createClass({ var MemberTile = sdk.getComponent("rooms.MemberTile"); var self = this; - return self.state.members.filter(function(userId) { + + var memberList = self.state.members.filter(function(userId) { var m = self.memberDict[userId]; return m.membership == membership; }).map(function(userId) { @@ -238,6 +277,31 @@ module.exports = React.createClass({ ); }); + + if (membership === "invite") { + // include 3pid invites (m.room.third_party_invite) state events. + // The HS may have already converted these into m.room.member invites so + // we shouldn't add them if the 3pid invite state key (token) is in the + // member invite (content.third_party_invite.signed.token) + var room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (room) { + room.currentState.getStateEvents("m.room.third_party_invite").forEach( + function(e) { + // discard all invites which have a m.room.member event since we've + // already added them. + var memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey()); + if (memberEvent) { + return; + } + memberList.push( + + ) + }) + } + } + + return memberList; }, onPopulateInvite: function(e) { @@ -254,7 +318,7 @@ module.exports = React.createClass({ } else { return (
    - +
    ); } diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 32cc619f13..0f40180274 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -26,20 +26,19 @@ var Modal = require("../../../Modal"); module.exports = React.createClass({ displayName: 'MemberTile', + propTypes: { + member: React.PropTypes.any, // RoomMember + onFinished: React.PropTypes.func, + customDisplayName: React.PropTypes.string // for 3pid invites + }, + getInitialState: function() { return {}; }, - onLeaveClick: function() { - dis.dispatch({ - action: 'leave_room', - room_id: this.props.member.roomId, - }); - this.props.onFinished(); - }, - shouldComponentUpdate: function(nextProps, nextState) { if (this.state.hover !== nextState.hover) return true; + if (!this.props.member) { return false; } // e.g. 3pid members if ( this.member_last_modified_time === undefined || this.member_last_modified_time < nextProps.member.getLastModifiedTime() @@ -65,117 +64,121 @@ module.exports = React.createClass({ }, onClick: function(e) { + if (!this.props.member) { return; } // e.g. 3pid members + dis.dispatch({ action: 'view_user', member: this.props.member, }); }, - getDuration: function(time) { - if (!time) return; - var t = parseInt(time / 1000); - var s = t % 60; - var m = parseInt(t / 60) % 60; - var h = parseInt(t / (60 * 60)) % 24; - var d = parseInt(t / (60 * 60 * 24)); - if (t < 60) { - if (t < 0) { - return "0s"; - } - return s + "s"; + _getDisplayName: function() { + if (this.props.customDisplayName) { + return this.props.customDisplayName; } - if (t < 60 * 60) { - return m + "m"; - } - if (t < 24 * 60 * 60) { - return h + "h"; - } - return d + "d "; - }, - - getPrettyPresence: function(user) { - if (!user) return "Unknown"; - var presence = user.presence; - if (presence === "online") return "Online"; - if (presence === "unavailable") return "Idle"; // XXX: is this actually right? - if (presence === "offline") return "Offline"; - return "Unknown"; + return this.props.member.name; }, getPowerLabel: function() { - var label = this.props.member.userId; - if (this.state.isTargetMod) { - label += " - Mod (" + this.props.member.powerLevelNorm + "%)"; + if (!this.props.member) { + return this._getDisplayName(); } + var label = this.props.member.userId + " (power " + this.props.member.powerLevel + ")"; return label; }, render: function() { - this.member_last_modified_time = this.props.member.getLastModifiedTime(); - if (this.props.member.user) { - this.user_last_modified_time = this.props.member.user.getLastModifiedTime(); - } - - var isMyUser = MatrixClientPeg.get().credentials.userId == this.props.member.userId; - - var power; - // if (this.props.member && this.props.member.powerLevelNorm > 0) { - // var img = "img/p/p" + Math.floor(20 * this.props.member.powerLevelNorm / 100) + ".png"; - // power = ; - // } + var member = this.props.member; + var isMyUser = false; + var name = this._getDisplayName(); + var active = -1; var presenceClass = "mx_MemberTile_offline"; - var mainClassName = "mx_MemberTile "; - if (this.props.member.user) { - if (this.props.member.user.presence === "online") { - presenceClass = "mx_MemberTile_online"; + + if (member) { + if (member.user) { + this.user_last_modified_time = member.user.getLastModifiedTime(); + + // FIXME: make presence data update whenever User.presence changes... + active = ( + (Date.now() - (member.user.lastPresenceTs - member.user.lastActiveAgo)) || -1 + ); + + if (member.user.presence === "online") { + presenceClass = "mx_MemberTile_online"; + } + else if (member.user.presence === "unavailable") { + presenceClass = "mx_MemberTile_unavailable"; + } } - else if (this.props.member.user.presence === "unavailable") { - presenceClass = "mx_MemberTile_unavailable"; + this.member_last_modified_time = member.getLastModifiedTime(); + isMyUser = MatrixClientPeg.get().credentials.userId == member.userId; + + // if (this.props.member && this.props.member.powerLevelNorm > 0) { + // var img = "img/p/p" + Math.floor(20 * this.props.member.powerLevelNorm / 100) + ".png"; + // power = ; + // } + + var power; + if (this.props.member) { + var powerLevel = this.props.member.powerLevel; + if (powerLevel >= 50 && powerLevel < 99) { + power = Mod; + } + if (powerLevel >= 99) { + power = Admin; + } } } + + var mainClassName = "mx_MemberTile "; mainClassName += presenceClass; if (this.state.hover) { mainClassName += " mx_MemberTile_hover"; } - var name = this.props.member.name; - // if (isMyUser) name += " (me)"; // this does nothing other than introduce line wrapping and pain - //var leave = isMyUser ? : null; - var nameEl; - if (this.state.hover) { - var presence; - // FIXME: make presence data update whenever User.presence changes... - var active = this.props.member.user ? ((Date.now() - (this.props.member.user.lastPresenceTs - this.props.member.user.lastActiveAgo)) || -1) : -1; - if (active >= 0) { - presence =
    { this.getPrettyPresence(this.props.member.user) } { this.getDuration(active) } ago
    ; - } - else { - presence =
    { this.getPrettyPresence(this.props.member.user) }
    ; - } - - nameEl = + if (this.state.hover && this.props.member) { + var presenceState = (member && member.user) ? member.user.presence : null; + var PresenceLabel = sdk.getComponent("rooms.PresenceLabel"); + nameEl = (
    { name }
    - { presence } +
    + ); } else { - nameEl = + nameEl = (
    { name }
    + ); } var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + + var av; + if (member) { + av = ( + + ); + } + else { + av = ( + + ); + } + return (
    - - { power } + { av } + { power }
    { nameEl }
    diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index f2894bd6b3..d5aaaa1128 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -75,7 +75,7 @@ module.exports = React.createClass({ // a callback which is called when the height of the composer is // changed due to a change in content. - onResize: React.PropTypes.function, + onResize: React.PropTypes.func, }, componentWillMount: function() { @@ -209,23 +209,18 @@ module.exports = React.createClass({ this.sentHistory.push(input); this.onEnter(ev); } - else if (ev.keyCode === KeyCode.UP) { - var input = this.refs.textarea.value; - var offset = this.refs.textarea.selectionStart || 0; - if (ev.ctrlKey || !input.substr(0, offset).match(/\n/)) { - this.sentHistory.next(1); - ev.preventDefault(); - this.resizeInput(); - } - } - else if (ev.keyCode === KeyCode.DOWN) { - var input = this.refs.textarea.value; - var offset = this.refs.textarea.selectionStart || 0; - if (ev.ctrlKey || !input.substr(offset).match(/\n/)) { - this.sentHistory.next(-1); - ev.preventDefault(); - this.resizeInput(); - } + else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { + var oldSelectionStart = this.refs.textarea.selectionStart; + // Remember the keyCode because React will recycle the synthetic event + var keyCode = ev.keyCode; + // set a callback so we can see if the cursor position changes as + // a result of this event. If it doesn't, we cycle history. + setTimeout(() => { + if (this.refs.textarea.selectionStart == oldSelectionStart) { + this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); + this.resizeInput(); + } + }, 0); } if (this.props.tabComplete) { @@ -341,7 +336,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); } - sendMessagePromise.then(function() { + sendMessagePromise.done(function() { dis.dispatch({ action: 'message_sent' }); diff --git a/src/components/views/rooms/PresenceLabel.js b/src/components/views/rooms/PresenceLabel.js new file mode 100644 index 0000000000..4ecad5b3df --- /dev/null +++ b/src/components/views/rooms/PresenceLabel.js @@ -0,0 +1,84 @@ +/* +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 MatrixClientPeg = require('../../../MatrixClientPeg'); +var sdk = require('../../../index'); + +module.exports = React.createClass({ + displayName: 'PresenceLabel', + + propTypes: { + activeAgo: React.PropTypes.number, + presenceState: React.PropTypes.string + }, + + getDefaultProps: function() { + return { + ago: -1, + presenceState: null + }; + }, + + getDuration: function(time) { + if (!time) return; + var t = parseInt(time / 1000); + var s = t % 60; + var m = parseInt(t / 60) % 60; + var h = parseInt(t / (60 * 60)) % 24; + var d = parseInt(t / (60 * 60 * 24)); + if (t < 60) { + if (t < 0) { + return "0s"; + } + return s + "s"; + } + if (t < 60 * 60) { + return m + "m"; + } + if (t < 24 * 60 * 60) { + return h + "h"; + } + return d + "d "; + }, + + getPrettyPresence: function(presence) { + if (presence === "online") return "Online"; + if (presence === "unavailable") return "Idle"; // XXX: is this actually right? + if (presence === "offline") return "Offline"; + return "Unknown"; + }, + + render: function() { + if (this.props.activeAgo >= 0) { + return ( +
    + { this.getPrettyPresence(this.props.presenceState) } { this.getDuration(this.props.activeAgo) } ago +
    + ); + } + else { + return ( +
    + { this.getPrettyPresence(this.props.presenceState) } +
    + ); + } + } +}); diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 8b5435e46a..5340798875 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -21,6 +21,12 @@ var sdk = require('../../../index'); var dis = require("../../../dispatcher"); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var linkify = require('linkifyjs'); +var linkifyElement = require('linkifyjs/element'); +var linkifyMatrix = require('../../../linkify-matrix'); + +linkifyMatrix(linkify); + module.exports = React.createClass({ displayName: 'RoomHeader', @@ -41,6 +47,25 @@ module.exports = React.createClass({ }; }, + componentWillReceiveProps: function(newProps) { + if (newProps.editing) { + var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); + var name = this.props.room.currentState.getStateEvents('m.room.name', ''); + + this.setState({ + name: name ? name.getContent().name : '', + defaultName: this.props.room.getDefaultRoomName(MatrixClientPeg.get().credentials.userId), + topic: topic ? topic.getContent().topic : '', + }); + } + }, + + componentDidUpdate: function() { + if (this.refs.topic) { + linkifyElement(this.refs.topic, linkifyMatrix.options); + } + }, + onVideoClick: function(e) { dis.dispatch({ action: 'place_call', @@ -57,26 +82,59 @@ module.exports = React.createClass({ }); }, - onNameChange: function(new_name) { - if (this.props.room.name != new_name && new_name) { - MatrixClientPeg.get().setRoomName(this.props.room.roomId, new_name); + onNameChanged: function(value) { + this.setState({ name : value }); + }, + + onTopicChanged: function(value) { + this.setState({ topic : value }); + }, + + onAvatarPickerClick: function(ev) { + if (this.refs.file_label) { + this.refs.file_label.click(); } }, + 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 + }); + }); + }, + getRoomName: function() { - return this.refs.name_edit.value; + return this.state.name; + }, + + getTopic: function() { + return this.state.topic; }, render: function() { var EditableText = sdk.getComponent("elements.EditableText"); - var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); + var RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); + var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar"); var TintableSvg = sdk.getComponent("elements.TintableSvg"); var header; if (this.props.simpleHeader) { var cancel; if (this.props.onCancelClick) { - cancel = Close + cancel = Close } header =
    @@ -87,27 +145,72 @@ module.exports = React.createClass({
    } else { - var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); - var name = null; var searchStatus = null; var topic_el = null; var cancel_button = null; var save_button = null; var settings_button = null; - var actual_name = this.props.room.currentState.getStateEvents('m.room.name', ''); - if (actual_name) actual_name = actual_name.getContent().name; if (this.props.editing) { - name = -
    - -
    - // if (topic) topic_el =
    - cancel_button =
    Cancel
    - save_button =
    Save Changes
    - } else { - // + // calculate permissions. XXX: this should be done on mount or something, and factored out with RoomSettings + var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); + var events_levels = (power_levels ? power_levels.events : {}) || {}; + var user_id = MatrixClientPeg.get().credentials.userId; + + if (power_levels) { + power_levels = power_levels.getContent(); + var default_user_level = parseInt(power_levels.users_default || 0); + var user_levels = power_levels.users || {}; + var current_user_level = user_levels[user_id]; + if (current_user_level == undefined) current_user_level = default_user_level; + } else { + var default_user_level = 0; + var user_levels = []; + var current_user_level = 0; + } + var state_default = parseInt((power_levels ? power_levels.state_default : 0) || 0); + + var room_avatar_level = state_default; + if (events_levels['m.room.avatar'] !== undefined) { + room_avatar_level = events_levels['m.room.avatar']; + } + var can_set_room_avatar = current_user_level >= room_avatar_level; + + var room_name_level = state_default; + if (events_levels['m.room.name'] !== undefined) { + room_name_level = events_levels['m.room.name']; + } + var can_set_room_name = current_user_level >= room_name_level; + + var room_topic_level = state_default; + if (events_levels['m.room.topic'] !== undefined) { + room_topic_level = events_levels['m.room.topic']; + } + var can_set_room_topic = current_user_level >= room_topic_level; + + var placeholderName = "Unnamed Room"; + if (this.state.defaultName && this.state.defaultName !== '?') { + placeholderName += " (" + this.state.defaultName + ")"; + } + + save_button =
    Save
    + cancel_button =
    Cancel
    + } + + if (can_set_room_name) { + name = +
    + +
    + } + else { var searchStatus; // don't display the search count until the search completes and // gives us a valid (possibly zero) searchCount. @@ -123,14 +226,48 @@ module.exports = React.createClass({
    - if (topic) topic_el =
    { topic.getContent().topic }
    ; + } + + if (can_set_room_topic) { + topic_el = + + } else { + var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); + if (topic) topic_el =
    { topic.getContent().topic }
    ; } var roomAvatar = null; if (this.props.room) { - roomAvatar = ( - - ); + if (can_set_room_avatar) { + roomAvatar = ( +
    +
    + +
    +
    + + +
    +
    + ); + } + else { + roomAvatar = ( +
    + +
    + ); + } } var leave_button; @@ -149,6 +286,18 @@ module.exports = React.createClass({
    ; } + var right_row; + if (!this.props.editing) { + right_row = +
    + { forget_button } + { leave_button } +
    + +
    +
    ; + } + header =
    @@ -160,20 +309,14 @@ module.exports = React.createClass({ { topic_el }
    - {cancel_button} {save_button} -
    - { forget_button } - { leave_button } -
    - -
    -
    + {cancel_button} + {right_row}
    } return ( -
    +
    { header }
    ); diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js new file mode 100644 index 0000000000..2f12c4c8e2 --- /dev/null +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -0,0 +1,56 @@ +/* +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'); + +module.exports = React.createClass({ + displayName: 'RoomPreviewBar', + + propTypes: { + onJoinClick: React.PropTypes.func, + canJoin: React.PropTypes.bool + }, + + getDefaultProps: function() { + return { + onJoinClick: function() {}, + canJoin: false + }; + }, + + render: function() { + var joinBlock; + + if (this.props.canJoin) { + joinBlock = ( +
    + Would you like to join this room? +
    + ); + } + + return ( +
    +
    + This is a preview of this room. Room interactions have been disabled. +
    + {joinBlock} +
    + ); + } +}); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index e402ac8488..284bee41c2 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -16,21 +16,98 @@ limitations under the License. var React = require('react'); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var Tinter = require('../../../Tinter'); var sdk = require('../../../index'); +var Modal = require('../../../Modal'); + +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', propTypes: { room: React.PropTypes.object.isRequired, + onSaveClick: React.PropTypes.func, + onCancelClick: React.PropTypes.func, + }, + + componentDidMount: function() { + // XXX: dirty hack to gutwrench to focus on the invite box + if (this.props.room.getJoinedMembers().length == 1) { + var inviteBox = document.getElementById("mx_MemberList_invite"); + if (inviteBox) setTimeout(function() { inviteBox.focus(); }, 0); + } }, 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; + } + + // get the aliases + var aliases = {}; + var domain = MatrixClientPeg.get().getDomain(); + var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases'); + for (var i = 0; i < alias_events.length; i++) { + aliases[alias_events[i].getStateKey()] = alias_events[i].getContent().aliases.slice(); // shallow copy + } + aliases[domain] = aliases[domain] || []; + + var tags = {}; + Object.keys(this.props.room.tags).forEach(function(tagName) { + tags[tagName] = {}; + }); + return { - power_levels_changed: false + power_levels_changed: false, + color_scheme_changed: false, + color_scheme_index: room_color_index, + aliases_changed: false, + aliases: aliases, + tags_changed: false, + tags: tags, }; }, + resetState: function() { + this.set.state(this.getInitialState()); + }, + canGuestsJoin: function() { return this.refs.guests_join.checked; }, @@ -40,7 +117,7 @@ module.exports = React.createClass({ }, getTopic: function() { - return this.refs.topic.value; + return this.refs.topic ? this.refs.topic.value : ""; }, getJoinRules: function() { @@ -62,13 +139,13 @@ module.exports = React.createClass({ power_levels = power_levels.getContent(); var new_power_levels = { - ban: parseInt(this.refs.ban.value), - kick: parseInt(this.refs.kick.value), - redact: parseInt(this.refs.redact.value), - invite: parseInt(this.refs.invite.value), - events_default: parseInt(this.refs.events_default.value), - state_default: parseInt(this.refs.state_default.value), - users_default: parseInt(this.refs.users_default.value), + ban: parseInt(this.refs.ban.getValue()), + kick: parseInt(this.refs.kick.getValue()), + redact: parseInt(this.refs.redact.getValue()), + invite: parseInt(this.refs.invite.getValue()), + events_default: parseInt(this.refs.events_default.getValue()), + state_default: parseInt(this.refs.state_default.getValue()), + users_default: parseInt(this.refs.users_default.getValue()), users: power_levels.users, events: power_levels.events, }; @@ -76,17 +153,231 @@ module.exports = React.createClass({ return new_power_levels; }, + getCanonicalAlias: function() { + return this.refs.canonical_alias ? this.refs.canonical_alias.value : ""; + }, + + getAliasOperations: function() { + if (!this.state.aliases_changed) return undefined; + + // work out the delta from room state to UI state + var ops = []; + + // calculate original ("old") aliases + var oldAliases = {}; + var aliases = this.state.aliases; + var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases'); + for (var i = 0; i < alias_events.length; i++) { + var domain = alias_events[i].getStateKey(); + oldAliases[domain] = alias_events[i].getContent().aliases.slice(); // shallow copy + } + + // FIXME: this whole delta-based set comparison function used for domains, aliases & tags + // should be factored out asap rather than duplicated like this. + + // work out whether any domains have entirely disappeared or appeared + var domainDelta = {} + Object.keys(oldAliases).forEach(function(domain) { + domainDelta[domain] = domainDelta[domain] || 0; + domainDelta[domain]--; + }); + Object.keys(aliases).forEach(function(domain) { + domainDelta[domain] = domainDelta[domain] || 0; + domainDelta[domain]++; + }); + + Object.keys(domainDelta).forEach(function(domain) { + switch (domainDelta[domain]) { + case 1: // entirely new domain + aliases[domain].forEach(function(alias) { + ops.push({ type: "put", alias : alias }); + }); + break; + case -1: // entirely removed domain + oldAliases[domain].forEach(function(alias) { + ops.push({ type: "delete", alias : alias }); + }); + break; + case 0: // mix of aliases in this domain. + // compare old & new aliases for this domain + var delta = {}; + oldAliases[domain].forEach(function(item) { + delta[item] = delta[item] || 0; + delta[item]--; + }); + aliases[domain].forEach(function(item) { + delta[item] = delta[item] || 0; + delta[item]++; + }); + + Object.keys(delta).forEach(function(alias) { + if (delta[alias] == 1) { + ops.push({ type: "put", alias: alias }); + } else if (delta[alias] == -1) { + ops.push({ type: "delete", alias: alias }); + } else { + console.error("Calculated alias delta of " + delta[alias] + + " - this should never happen!"); + } + }); + break; + default: + console.error("Calculated domain delta of " + domainDelta[domain] + + " - this should never happen!"); + break; + } + }); + + return ops; + }, + + getTagOperations: function() { + if (!this.state.tags_changed) return undefined; + + var ops = []; + + var delta = {}; + Object.keys(this.props.room.tags).forEach(function(oldTag) { + delta[oldTag] = delta[oldTag] || 0; + delta[oldTag]--; + }); + Object.keys(this.state.tags).forEach(function(newTag) { + delta[newTag] = delta[newTag] || 0; + delta[newTag]++; + }); + Object.keys(delta).forEach(function(tag) { + if (delta[tag] == 1) { + ops.push({ type: "put", tag: tag }); + } else if (delta[tag] == -1) { + ops.push({ type: "delete", tag: tag }); + } else { + console.error("Calculated tag delta of " + delta[tag] + + " - this should never happen!"); + } + }); + + return ops; + }, + onPowerLevelsChanged: function() { this.setState({ power_levels_changed: true }); }, - render: function() { - var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); + getColorScheme: function() { + if (!this.state.color_scheme_changed) return undefined; - var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); - if (topic) topic = topic.getContent().topic; + 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, + }); + }, + + onAliasChanged: function(domain, index, alias) { + if (alias === "") return; // hit the delete button to delete please + var oldAlias; + if (this.isAliasValid(alias)) { + oldAlias = this.state.aliases[domain][index]; + this.state.aliases[domain][index] = alias; + this.setState({ aliases_changed : true }); + } + else { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Invalid alias format", + description: "'" + alias + "' is not a valid format for an alias", + }); + } + }, + + onAliasDeleted: function(domain, index) { + // It's a bit naughty to directly manipulate this.state, and React would + // normally whine at you, but it can't see us doing the splice. Given we + // promptly setState anyway, it's just about acceptable. The alternative + // would be to arbitrarily deepcopy to a temp variable and then setState + // that, but why bother when we can cut this corner. + var alias = this.state.aliases[domain].splice(index, 1); + this.setState({ + aliases: this.state.aliases + }); + + this.setState({ aliases_changed : true }); + }, + + onAliasAdded: function(alias) { + if (alias === "") return; // ignore attempts to create blank aliases + if (alias === undefined) { + alias = this.refs.add_alias ? this.refs.add_alias.getValue() : undefined; + if (alias === undefined || alias === "") return; + } + + if (this.isAliasValid(alias)) { + var domain = alias.replace(/^.*?:/, ''); + // XXX: do we need to deep copy aliases before editing it? + this.state.aliases[domain] = this.state.aliases[domain] || []; + this.state.aliases[domain].push(alias); + this.setState({ + aliases: this.state.aliases + }); + + // reset the add field + this.refs.add_alias.setValue(''); + + this.setState({ aliases_changed : true }); + } + else { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Invalid alias format", + description: "'" + alias + "' is not a valid format for an alias", + }); + } + }, + + isAliasValid: function(alias) { + // XXX: FIXME SPEC-1 + return (alias.match(/^#([^\/:,]+?):(.+)$/) && encodeURI(alias) === alias); + }, + + onTagChange: function(tagName, event) { + if (event.target.checked) { + if (tagName === 'm.favourite') { + delete this.state.tags['m.lowpriority']; + } + else if (tagName === 'm.lowpriority') { + delete this.state.tags['m.favourite']; + } + + this.state.tags[tagName] = this.state.tags[tagName] || {}; + } + else { + delete this.state.tags[tagName]; + } + + // XXX: hacky say to deep-edit state + this.setState({ + tags: this.state.tags, + tags_changed: true + }); + }, + + render: function() { + // TODO: go through greying out things you don't have permission to change + // (or turning them into informative stuff) + + var EditableText = sdk.getComponent('elements.EditableText'); + var PowerSelector = sdk.getComponent('elements.PowerSelector'); var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', ''); if (join_rule) join_rule = join_rule.getContent().join_rule; @@ -108,7 +399,10 @@ module.exports = React.createClass({ } } - var events_levels = power_levels.events || {}; + var events_levels = (power_levels ? power_levels.events : {}) || {}; + + var user_id = MatrixClientPeg.get().credentials.userId; + if (power_levels) { power_levels = power_levels.getContent(); @@ -127,8 +421,6 @@ module.exports = React.createClass({ var user_levels = power_levels.users || {}; - var user_id = MatrixClientPeg.get().credentials.userId; - var current_user_level = user_levels[user_id]; if (current_user_level == undefined) current_user_level = default_user_level; @@ -157,117 +449,308 @@ module.exports = React.createClass({ var can_change_levels = false; } - var room_avatar_level = parseInt(power_levels.state_default || 0); - if (events_levels['m.room.avatar'] !== undefined) { - room_avatar_level = events_levels['m.room.avatar']; - } - var can_set_room_avatar = current_user_level >= room_avatar_level; + var state_default = (parseInt(power_levels ? power_levels.state_default : 0) || 0); - var change_avatar; - if (can_set_room_avatar) { - change_avatar =
    -

    Room Icon

    - + var room_aliases_level = state_default; + if (events_levels['m.room.aliases'] !== undefined) { + room_avatar_level = events_levels['m.room.aliases']; + } + var can_set_room_aliases = current_user_level >= room_aliases_level; + + var canonical_alias_level = state_default; + if (events_levels['m.room.canonical_alias'] !== undefined) { + room_avatar_level = events_levels['m.room.canonical_alias']; + } + var can_set_canonical_alias = current_user_level >= canonical_alias_level; + + var tag_level = state_default; + if (events_levels['m.tag'] !== undefined) { + tag_level = events_levels['m.tag']; + } + var can_set_tag = current_user_level >= tag_level; + + var self = this; + + var canonical_alias_event = this.props.room.currentState.getStateEvents('m.room.canonical_alias', ''); + var canonical_alias = canonical_alias_event ? canonical_alias_event.getContent().alias : ""; + var domain = MatrixClientPeg.get().getDomain(); + + var remote_domains = Object.keys(this.state.aliases).filter(function(alias) { return alias !== domain }); + + var remote_aliases_section; + if (remote_domains.length) { + remote_aliases_section = +
    +
    + This room can be found elsewhere as: +
    +
    + { remote_domains.map(function(state_key, i) { + self.state.aliases[state_key].map(function(alias, j) { + return ( +
    + +
    +
    +
    + ); + }); + })} +
    +
    + } + + var canonical_alias_section; + if (can_set_canonical_alias) { + canonical_alias_section = + + } + else { + canonical_alias_section = { canonical_alias || "not set" }; + } + + var aliases_section = +
    +

    Directory

    +
    + { this.state.aliases[domain].length + ? "This room can be found on " + domain + " as:" + : "This room is not findable on " + domain } +
    +
    + { this.state.aliases[domain].map(function(alias, i) { + var deleteButton; + if (can_set_room_aliases) { + deleteButton = Delete; + } + return ( +
    + +
    + { deleteButton } +
    +
    + ); + })} + +
    + +
    + Add +
    +
    +
    + + { remote_aliases_section } + +
    The official way to refer to this room is: { canonical_alias_section }
    ; + + var room_colors_section = +
    +

    Room Colour

    +
    + {room_colors.map(function(room_color, i) { + var selected; + if (i === self.state.color_scheme_index) { + selected = +
    + ./ +
    + } + var boundClick = self.onColorSchemeChanged.bind(self, i) + return ( +
    + { selected } +
    +
    + ); + })} +
    +
    ; + + var user_levels_section; + if (user_levels.length) { + user_levels_section = +
    +
    + Users with specific roles are: +
    +
    + {Object.keys(user_levels).map(function(user, i) { + return ( +
    + { user } is a + +
    + ); + })} +
    +
    ; + } + else { + user_levels_section =
    No users have specific privileges in this room.
    } var banned = this.props.room.getMembersWithMembership("ban"); + var banned_users_section; + if (banned.length) { + banned_users_section = +
    +

    Banned users

    +
    + {banned.map(function(member, i) { + return ( +
    + {member.userId} +
    + ); + })} +
    +
    ; + } + + var create_event = this.props.room.currentState.getStateEvents('m.room.create', ''); + var unfederatable_section; + if (create_event.getContent()["m.federate"] === false) { + unfederatable_section =
    Ths room is not accessible by remote Matrix servers.
    + } + + // TODO: support editing custom events_levels + // TODO: support editing custom user_levels + + var tags = [ + { name: "m.favourite", label: "Favourite", ref: "tag_favourite" }, + { name: "m.lowpriority", label: "Low priority", ref: "tag_lowpriority" }, + ]; + + Object.keys(this.state.tags).sort().forEach(function(tagName) { + if (tagName !== 'm.favourite' && tagName !== 'm.lowpriority') { + tags.push({ name: tagName, label: tagName }); + } + }); + + var tags_section = +
    + This room is tagged as + { can_set_tag ? + tags.map(function(tag, i) { + return (); + }) : tags.map(function(tag) { return tag.label; }).join(", ") + } +
    return (
    -