diff --git a/package.json b/package.json index 0c9054122f..4453e7f65c 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,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/AddThreepid.js b/src/AddThreepid.js new file mode 100644 index 0000000000..31805aad11 --- /dev/null +++ b/src/AddThreepid.js @@ -0,0 +1,76 @@ +/* +Copyright 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 MatrixClientPeg = require("./MatrixClientPeg"); + +/** + * Allows a user to add a third party identifier to their Home Server and, + * optionally, the identity servers. + * + * 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 + * add threepid API on the homeserver. + */ +class AddThreepid { + constructor() { + this.clientSecret = MatrixClientPeg.get().generateClientSecret(); + } + + /** + * Attempt to add an email threepid. This will trigger a side-effect of + * sending an email to the provided email address. + * @param {string} emailAddress The email address to add + * @param {boolean} bind If True, bind this email to this mxid on the Identity Server + * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). + */ + addEmailAddress(emailAddress, bind) { + this.bind = bind; + return MatrixClientPeg.get().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 add the threepid + * @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() { + var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1]; + return MatrixClientPeg.get().addThreePid({ + sid: this.sessionId, + client_secret: this.clientSecret, + id_server: identityServerDomain + }, this.bind).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) { + err.message += ` (Status ${err.httpStatus})`; + } + throw err; + }); + } +} + +module.exports = AddThreepid; diff --git a/src/Avatar.js b/src/Avatar.js index e97ed6b673..0ef6c8d07b 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -15,7 +15,7 @@ limitations under the License. */ 'use strict'; - +var ContentRepo = require("matrix-js-sdk").ContentRepo; var MatrixClientPeg = require('./MatrixClientPeg'); module.exports = { @@ -37,6 +37,17 @@ module.exports = { return url; }, + avatarUrlForUser: function(user, width, height, resizeMethod) { + var url = ContentRepo.getHttpUriForMxc( + MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl, + width, height, resizeMethod + ); + if (!url || url.length === 0) { + return null; + } + return url; + }, + defaultAvatarUrlForString: function(s) { var images = [ '76cfa6', '50e2c2', 'f4c371' ]; var total = 0; diff --git a/src/Entities.js b/src/Entities.js new file mode 100644 index 0000000000..47103bfb65 --- /dev/null +++ b/src/Entities.js @@ -0,0 +1,107 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var React = require('react'); +var sdk = require('./index'); + +/* + * Converts various data models to Entity objects. + * + * Entity objects provide an interface for UI components to use to display + * members in a data-agnostic way. This means they don't need to care if the + * underlying data model is a RoomMember, User or 3PID data structure, it just + * cares about rendering. + */ + +class Entity { + constructor(model) { + this.model = model; + } + + getJsx() { + return null; + } + + matches(queryString) { + return false; + } +} + +class MemberEntity extends Entity { + getJsx() { + var MemberTile = sdk.getComponent("rooms.MemberTile"); + return ( + + ); + } + + matches(queryString) { + return this.model.name.toLowerCase().indexOf(queryString.toLowerCase()) === 0; + } +} + +class UserEntity extends Entity { + + constructor(model, showInviteButton, inviteFn) { + super(model); + this.showInviteButton = Boolean(showInviteButton); + this.inviteFn = inviteFn; + } + + onClick() { + if (this.inviteFn) { + this.inviteFn(this.model.userId); + } + } + + getJsx() { + var UserTile = sdk.getComponent("rooms.UserTile"); + return ( + + ); + } + + matches(queryString) { + var name = this.model.displayName || this.model.userId; + return name.toLowerCase().indexOf(queryString.toLowerCase()) === 0; + } +} + + +module.exports = { + /** + * @param {RoomMember[]} members + * @return {Entity[]} + */ + fromRoomMembers: function(members) { + return members.map(function(m) { + return new MemberEntity(m); + }); + }, + + /** + * @param {User[]} users + * @param {boolean} showInviteButton + * @param {Function} inviteFn Called with the user ID. + * @return {Entity[]} + */ + fromUsers: function(users, showInviteButton, inviteFn) { + return users.map(function(u) { + return new UserEntity(u, showInviteButton, inviteFn); + }) + } +}; diff --git a/src/GuestAccess.js b/src/GuestAccess.js new file mode 100644 index 0000000000..ef48d23ded --- /dev/null +++ b/src/GuestAccess.js @@ -0,0 +1,51 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +const IS_GUEST_KEY = "matrix-is-guest"; + +class GuestAccess { + + constructor(localStorage) { + this.localStorage = localStorage; + try { + this._isGuest = localStorage.getItem(IS_GUEST_KEY) === "true"; + } + catch (e) {} // don't care + } + + setPeekedRoom(roomId) { + // we purposefully do not persist this to local storage as peeking is + // entirely transient. + this._peekedRoomId = roomId; + } + + getPeekedRoom() { + return this._peekedRoomId; + } + + isGuest() { + return this._isGuest; + } + + markAsGuest(isGuest) { + try { + this.localStorage.setItem(IS_GUEST_KEY, JSON.stringify(isGuest)); + } catch (e) {} // ignore. If they don't do LS, they'll just get a new account. + this._isGuest = isGuest; + this._peekedRoomId = null; + } +} + +module.exports = GuestAccess; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 4a83ed09d9..dbb3dbf83e 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -18,6 +18,7 @@ limitations under the License. // A thing that holds your Matrix Client var Matrix = require("matrix-js-sdk"); +var GuestAccess = require("./GuestAccess"); var matrixClient = null; @@ -33,7 +34,7 @@ function deviceId() { return id; } -function createClient(hs_url, is_url, user_id, access_token) { +function createClient(hs_url, is_url, user_id, access_token, guestAccess) { var opts = { baseUrl: hs_url, idBaseUrl: is_url, @@ -47,6 +48,15 @@ function createClient(hs_url, is_url, user_id, access_token) { } matrixClient = Matrix.createClient(opts); + if (guestAccess) { + console.log("Guest: %s", guestAccess.isGuest()); + matrixClient.setGuest(guestAccess.isGuest()); + var peekedRoomId = guestAccess.getPeekedRoom(); + if (peekedRoomId) { + console.log("Peeking in room %s", peekedRoomId); + matrixClient.peekInRoom(peekedRoomId); + } + } } if (localStorage) { @@ -54,12 +64,18 @@ if (localStorage) { var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; var access_token = localStorage.getItem("mx_access_token"); var user_id = localStorage.getItem("mx_user_id"); + var guestAccess = new GuestAccess(localStorage); if (access_token && user_id && hs_url) { - createClient(hs_url, is_url, user_id, access_token); + createClient(hs_url, is_url, user_id, access_token, guestAccess); } } class MatrixClient { + + constructor(guestAccess) { + this.guestAccess = guestAccess; + } + get() { return matrixClient; } @@ -97,7 +113,7 @@ class MatrixClient { } } - replaceUsingAccessToken(hs_url, is_url, user_id, access_token) { + replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) { if (localStorage) { try { localStorage.clear(); @@ -105,7 +121,8 @@ class MatrixClient { console.warn("Error using local storage"); } } - createClient(hs_url, is_url, user_id, access_token); + this.guestAccess.markAsGuest(Boolean(isGuest)); + createClient(hs_url, is_url, user_id, access_token, this.guestAccess); if (localStorage) { try { localStorage.setItem("mx_hs_url", hs_url); @@ -122,6 +139,6 @@ class MatrixClient { } if (!global.mxMatrixClient) { - global.mxMatrixClient = new MatrixClient(); + global.mxMatrixClient = new MatrixClient(new GuestAccess(localStorage)); } module.exports = global.mxMatrixClient; diff --git a/src/PasswordReset.js b/src/PasswordReset.js new file mode 100644 index 0000000000..bbafa0ef33 --- /dev/null +++ b/src/PasswordReset.js @@ -0,0 +1,92 @@ +/* +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 = this.client.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; + }); + } +} + +module.exports = PasswordReset; diff --git a/src/Presence.js b/src/Presence.js index 5c9d6945a3..4152d7a487 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -73,6 +73,11 @@ class Presence { } var old_state = this.state; this.state = newState; + + if (MatrixClientPeg.get().isGuest()) { + return; // don't try to set presence when a guest; it won't work. + } + var self = this; MatrixClientPeg.get().setPresence(this.state).done(function() { console.log("Presence: %s", newState); diff --git a/src/Signup.js b/src/Signup.js index 74c4ad5f19..fbc2a09634 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -69,6 +69,10 @@ class Register extends Signup { this.params.idSid = idSid; } + setGuestAccessToken(token) { + this.guestAccessToken = token; + } + getStep() { return this._step; } @@ -126,7 +130,8 @@ class Register extends Signup { } return MatrixClientPeg.get().register( - this.username, this.password, this.params.sessionId, authDict, bindEmail + this.username, this.password, this.params.sessionId, authDict, bindEmail, + this.guestAccessToken ).then(function(result) { self.credentials = result; self.setStep("COMPLETE"); @@ -147,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 2c1f25a2d4..d4e7df3a16 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -18,6 +18,32 @@ var MatrixClientPeg = require("./MatrixClientPeg"); var MatrixTools = require("./MatrixTools"); 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 { @@ -33,16 +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()); + }), - encrypt: function(room_id, args) { + // 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: new Command("encrypt", "", function(room_id, args) { if (args == "on") { var client = MatrixClientPeg.get(); var members = client.getRoom(room_id).currentState.members; @@ -58,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) { @@ -81,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) { @@ -94,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 @@ -128,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 @@ -175,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) { @@ -187,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) { @@ -200,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) { @@ -214,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 @@ -243,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) { @@ -266,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 = { /** @@ -291,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 new file mode 100644 index 0000000000..3612be5b10 --- /dev/null +++ b/src/Tinter.js @@ -0,0 +1,211 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var dis = require("./dispatcher"); + +// FIXME: these vars should be bundled up and attached to +// module.exports otherwise this will break when included by both +// react-sdk and apps layered on top. + +// The colour keys to be replaced as referred to in SVGs +var keyRgb = [ + "rgb(118, 207, 166)", // Vector Green + "rgb(234, 245, 240)", // Vector Light Green + "rgba(118, 207, 166, 0.2)", // BottomLeftMenu overlay (20% Vector Green) +]; + +// Some algebra workings for calculating the tint % of Vector Green & Light Green +// x * 118 + (1 - x) * 255 = 234 +// x * 118 + 255 - 255 * x = 234 +// x * 118 - x * 255 = 234 - 255 +// (255 - 118) x = 255 - 234 +// x = (255 - 234) / (255 - 118) = 0.16 + +// The colour keys to be replaced as referred to in SVGs +var keyHex = [ + "#76CFA6", // Vector Green + "#EAF5F0", // Vector Light Green + "#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green) +]; + +// cache of our replacement colours +// defaults to our keys. +var colors = [ + keyHex[0], + keyHex[1], + keyHex[2], +]; + +var cssFixups = [ + // { + // style: a style object that should be fixed up taken from a stylesheet + // attr: name of the attribute to be clobbered, e.g. 'color' + // index: ordinal of primary, secondary or tertiary + // } +]; + +// CSS attributes to be fixed up +var cssAttrs = [ + "color", + "backgroundColor", + "borderColor", + "borderTopColor", + "borderBottomColor", +]; + +var svgAttrs = [ + "fill", + "stroke", +]; + +var cached = false; + +function calcCssFixups() { + for (var i = 0; i < document.styleSheets.length; i++) { + var ss = document.styleSheets[i]; + for (var j = 0; j < ss.cssRules.length; j++) { + var rule = ss.cssRules[j]; + if (!rule.style) continue; + for (var k = 0; k < cssAttrs.length; k++) { + var attr = cssAttrs[k]; + for (var l = 0; l < keyRgb.length; l++) { + if (rule.style[attr] === keyRgb[l]) { + cssFixups.push({ + style: rule.style, + attr: attr, + index: l, + }); + } + } + } + } + } +} + +function applyCssFixups() { + for (var i = 0; i < cssFixups.length; i++) { + var cssFixup = cssFixups[i]; + cssFixup.style[cssFixup.attr] = colors[cssFixup.index]; + } +} + +function hexToRgb(color) { + if (color[0] === '#') color = color.slice(1); + if (color.length === 3) { + color = color[0] + color[0] + + color[1] + color[1] + + color[2] + color[2]; + } + var val = parseInt(color, 16); + var r = (val >> 16) & 255; + var g = (val >> 8) & 255; + var b = val & 255; + return [r, g, b]; +} + +function rgbToHex(rgb) { + var val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; + return '#' + (0x1000000 + val).toString(16).slice(1) +} + +module.exports = { + tint: function(primaryColor, secondaryColor, tertiaryColor) { + if (!cached) { + calcCssFixups(); + 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); + rgb[0] = x * rgb[0] + (1 - x) * 255; + rgb[1] = x * rgb[1] + (1 - x) * 255; + rgb[2] = x * rgb[2] + (1 - x) * 255; + secondaryColor = rgbToHex(rgb); + } + + if (!tertiaryColor) { + var x = 0.19; + var rgb1 = hexToRgb(primaryColor); + var rgb2 = hexToRgb(secondaryColor); + rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; + rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1]; + rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2]; + 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. + applyCssFixups(); + + // tell all the SVGs to go fix themselves up + dis.dispatch({ action: 'tint_update' }); + }, + + // XXX: we could just move this all into TintableSvg, but as it's so similar + // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) + // keeping it here for now. + calcSvgFixups: function(svgs) { + // go through manually fixing up SVG colours. + // we could do this by stylesheets, but keeping the stylesheets + // updated would be a PITA, so just brute-force search for the + // key colour; cache the element and apply. + + var fixups = []; + for (var i = 0; i < svgs.length; i++) { + var svgDoc = svgs[i].contentDocument; + if (!svgDoc) continue; + var tags = svgDoc.getElementsByTagName("*"); + for (var j = 0; j < tags.length; j++) { + var tag = tags[j]; + for (var k = 0; k < svgAttrs.length; k++) { + var attr = svgAttrs[k]; + for (var l = 0; l < keyHex.length; l++) { + if (tag.getAttribute(attr) && tag.getAttribute(attr).toUpperCase() === keyHex[l]) { + fixups.push({ + node: tag, + attr: attr, + index: l, + }); + } + } + } + } + } + + return fixups; + }, + + applySvgFixups: function(fixups) { + for (var i = 0; i < fixups.length; i++) { + var svgFixup = fixups[i]; + svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]); + } + }, +}; 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/UserSettingsStore.js b/src/UserSettingsStore.js index 1b1e8810a9..45aca1f0dc 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -15,7 +15,7 @@ limitations under the License. */ 'use strict'; - +var q = require("q"); var MatrixClientPeg = require("./MatrixClientPeg"); var Notifier = require("./Notifier"); @@ -35,6 +35,11 @@ module.exports = { }, loadThreePids: function() { + if (MatrixClientPeg.get().isGuest()) { + return q({ + threepids: [] + }); // guests can't poke 3pid endpoint + } return MatrixClientPeg.get().getThreePids(); }, diff --git a/src/Velociraptor.js b/src/Velociraptor.js index d973a17f7f..066b1e2d05 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -36,7 +36,7 @@ module.exports = React.createClass({ var old = oldChildren[c.key]; var oldNode = ReactDom.findDOMNode(self.nodes[old.key]); - if (oldNode.style.left != c.props.style.left) { + if (oldNode && oldNode.style.left != c.props.style.left) { Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() { // special case visibility because it's nonsensical to animate an invisible element // so we always hidden->visible pre-transition and visible->hidden after @@ -73,6 +73,7 @@ module.exports = React.createClass({ collectNode: function(k, node) { if ( + node && this.nodes[k] === undefined && node.props.startStyle && Object.keys(node.props.startStyle).length diff --git a/src/component-index.js b/src/component-index.js index ed690b2fc5..2446b26b8d 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'); @@ -39,8 +44,11 @@ module.exports.components['views.create_room.RoomAlias'] = require('./components module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); 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'); module.exports.components['views.login.CaptchaForm'] = require('./components/views/login/CaptchaForm'); module.exports.components['views.login.CasLogin'] = require('./components/views/login/CasLogin'); @@ -57,17 +65,22 @@ module.exports.components['views.messages.MVideoBody'] = require('./components/v module.exports.components['views.messages.TextualBody'] = require('./components/views/messages/TextualBody'); module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent'); module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); +module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.MemberInfo'] = require('./components/views/rooms/MemberInfo'); 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.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList'); module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile'); module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar'); +module.exports.components['views.rooms.UserTile'] = require('./components/views/rooms/UserTile'); module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar'); module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); 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 ab9a73f77e..799cd58d93 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -31,6 +31,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"); @@ -43,6 +44,7 @@ module.exports = React.createClass({ ConferenceHandler: React.PropTypes.any, onNewScreen: React.PropTypes.func, registrationUrl: React.PropTypes.string, + enableGuest: React.PropTypes.bool, startingQueryParams: React.PropTypes.object }, @@ -63,7 +65,8 @@ module.exports = React.createClass({ collapse_lhs: false, collapse_rhs: false, ready: false, - width: 10000 + width: 10000, + autoPeek: true, // by default, we peek into rooms when we try to join them }; if (s.logged_in) { if (MatrixClientPeg.get().getRooms().length) { @@ -88,8 +91,21 @@ module.exports = React.createClass({ }, componentDidMount: function() { + this._autoRegisterAsGuest = false; + if (this.props.enableGuest) { + if (!this.props.config || !this.props.config.default_hs_url) { + console.error("Cannot enable guest access: No supplied config prop for HS/IS URLs"); + } + else { + this._autoRegisterAsGuest = true; + } + } + this.dispatcherRef = dis.register(this.onAction); if (this.state.logged_in) { + // Don't auto-register as a guest. This applies if you refresh the page on a + // logged in client THEN hit the Sign Out button. + this._autoRegisterAsGuest = false; this.startMatrixClient(); } this.focusComposer = false; @@ -98,8 +114,11 @@ module.exports = React.createClass({ this.scrollStateMap = {}; document.addEventListener("keydown", this.onKeyDown); window.addEventListener("focus", this.onFocus); + if (this.state.logged_in) { this.notifyNewScreen(''); + } else if (this._autoRegisterAsGuest) { + this._registerAsGuest(); } else { this.notifyNewScreen('login'); } @@ -131,6 +150,34 @@ module.exports = React.createClass({ } }, + _registerAsGuest: function() { + var self = this; + var config = this.props.config; + console.log("Doing guest login on %s", config.default_hs_url); + MatrixClientPeg.replaceUsingUrls( + config.default_hs_url, config.default_is_url + ); + MatrixClientPeg.get().registerGuest().done(function(creds) { + console.log("Registered as guest: %s", creds.user_id); + self._setAutoRegisterAsGuest(false); + self.onLoggedIn({ + userId: creds.user_id, + accessToken: creds.access_token, + homeserverUrl: config.default_hs_url, + identityServerUrl: config.default_is_url, + guest: true + }); + }, function(err) { + console.error(err.data); + self._setAutoRegisterAsGuest(false); + }); + }, + + _setAutoRegisterAsGuest: function(shouldAutoRegister) { + this._autoRegisterAsGuest = shouldAutoRegister; + this.forceUpdate(); + }, + onAction: function(payload) { var roomIndexDelta = 1; @@ -185,6 +232,21 @@ module.exports = React.createClass({ screen: 'post_registration' }); break; + case 'start_upgrade_registration': + this.replaceState({ + screen: "register", + upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(), + guestAccessToken: MatrixClientPeg.get().getAccessToken() + }); + 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; @@ -248,7 +310,10 @@ module.exports = React.createClass({ }); break; case 'view_room': - this._viewRoom(payload.room_id); + // by default we autoPeek rooms, unless we were called explicitly with + // autoPeek=false by something like RoomDirectory who has already peeked + this.setState({ autoPeek : payload.auto_peek === false ? false : true }); + this._viewRoom(payload.room_id, payload.show_settings); break; case 'view_prev_room': roomIndexDelta = -1; @@ -301,8 +366,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); @@ -343,7 +429,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(); @@ -363,7 +449,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; } @@ -372,6 +467,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 @@ -387,10 +485,11 @@ module.exports = React.createClass({ }, onLoggedIn: function(credentials) { - console.log("onLoggedIn => %s", credentials.userId); + credentials.guest = Boolean(credentials.guest); + console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest); MatrixClientPeg.replaceUsingAccessToken( credentials.homeserverUrl, credentials.identityServerUrl, - credentials.userId, credentials.accessToken + credentials.userId, credentials.accessToken, credentials.guest ); this.setState({ screen: undefined, @@ -457,7 +556,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, }); }, @@ -511,6 +612,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', @@ -566,6 +672,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, { @@ -573,6 +681,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) { @@ -620,10 +736,22 @@ module.exports = React.createClass({ this.showScreen("login"); }, + onForgotPasswordClick: function() { + this.showScreen("forgot_password"); + }, + onRegistered: function(credentials) { this.onLoggedIn(credentials); // do post-registration stuff - this.showScreen("post_registration"); + // This now goes straight to user settings + // We use _setPage since if we wait for + // showScreen to do the dispatch loop, + // the showScreen dispatch will race with the + // sdk sync finishing and we'll probably see + // the page type still unset when the MatrixClient + // is started and show the Room Directory instead. + //this.showScreen("view_user_settings"); + this._setPage(this.PageTypes.UserSettings); }, onFinishPostRegistration: function() { @@ -639,7 +767,9 @@ module.exports = React.createClass({ var rooms = MatrixClientPeg.get().getRooms(); for (var i = 0; i < rooms.length; ++i) { - if (rooms[i].getUnreadNotificationCount()) { + if (rooms[i].hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite')) { + ++notifCount; + } else if (rooms[i].getUnreadNotificationCount()) { notifCount += rooms[i].getUnreadNotificationCount(); } } @@ -671,6 +801,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') { @@ -689,6 +820,7 @@ module.exports = React.createClass({ ); @@ -734,12 +866,20 @@ module.exports = React.createClass({
); } - } else if (this.state.logged_in) { + } else if (this.state.logged_in || (!this.state.logged_in && this._autoRegisterAsGuest)) { var Spinner = sdk.getComponent('elements.Spinner'); + var logoutLink; + if (this.state.logged_in) { + logoutLink = ( + + Logout + + ); + } return (
- Logout + {logoutLink}
); } else if (this.state.screen == 'register') { @@ -749,19 +889,30 @@ module.exports = React.createClass({ sessionId={this.state.register_session_id} idSid={this.state.register_id_sid} email={this.props.startingQueryParams.email} + username={this.state.upgradeUsername} + disableUsernameChanges={Boolean(this.state.upgradeUsername)} + guestAccessToken={this.state.guestAccessToken} hsUrl={this.props.config.default_hs_url} isUrl={this.props.config.default_is_url} registrationUrl={this.props.registrationUrl} 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 177b4ab1cc..1fc30a8eec 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; @@ -53,7 +57,9 @@ if (DEBUG_SCROLL) { module.exports = React.createClass({ displayName: 'RoomView', propTypes: { - ConferenceHandler: React.PropTypes.any + ConferenceHandler: React.PropTypes.any, + roomId: React.PropTypes.string, + autoPeek: React.PropTypes.bool, // should we try to peek the room on mount, or has whoever invoked us already initiated a peek? }, /* properties in RoomView objects include: @@ -74,13 +80,21 @@ module.exports = React.createClass({ syncState: MatrixClientPeg.get().getSyncState(), hasUnsentMessages: this._hasUnsentMessages(room), callState: null, + autoPeekDone: false, // track whether our autoPeek (if any) has completed) + guestsCanJoin: false, + canPeek: false, + readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null, + readMarkerGhostEventId: undefined } }, componentWillMount: function() { + this.last_rr_sent_event_id = undefined; this.dispatcherRef = dis.register(this.onAction); + MatrixClientPeg.get().on("Room", this.onNewRoom); 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 +102,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, @@ -97,24 +109,56 @@ module.exports = React.createClass({ this.forceUpdate(); } }); + // if this is an unknown room then we're in one of three states: + // - This is a room we can peek into (search engine) (we can /peek) + // - This is a room we can publicly join or were invited to. (we can /join) + // - This is a room we cannot join at all. (no action can help us) + // We can't try to /join because this may implicitly accept invites (!) + // We can /peek though. If it fails then we present the join UI. If it + // succeeds then great, show the preview (but we still may be able to /join!). + if (!this.state.room) { + if (this.props.autoPeek) { + console.log("Attempting to peek into room %s", this.props.roomId); + MatrixClientPeg.get().peekInRoom(this.props.roomId).catch((err) => { + console.error("Failed to peek into room: %s", err); + }).finally(() => { + // we don't need to do anything - JS SDK will emit Room events + // which will update the UI. + this.setState({ + autoPeekDone: true + }); + }); + } + } + else { + this._calculatePeekRules(this.state.room); + } }, componentWillUnmount: function() { - if (this.refs.messagePanel) { - // disconnect the D&D event listeners from the message panel. This - // is really just for hygiene - the messagePanel is going to be + // set a boolean to say we've been unmounted, which any pending + // promises can use to throw away their results. + // + // (We could use isMounted, but facebook have deprecated that.) + this.unmounted = true; + + 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", this.onNewRoom); 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); @@ -122,6 +166,8 @@ module.exports = React.createClass({ } window.removeEventListener('resize', this.onResize); + + Tinter.tint(); // reset colourscheme }, onAction: function(payload) { @@ -175,6 +221,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; } @@ -196,7 +248,7 @@ module.exports = React.createClass({ },*/ onRoomTimeline: function(ev, room, toStartOfTimeline) { - if (!this.isMounted()) return; + if (this.unmounted) return; // ignore anything that comes in whilst paginating: we get one // event for each new matrix event so this would cause a huge @@ -227,6 +279,32 @@ module.exports = React.createClass({ }); }, + onNewRoom: function(room) { + if (room.roomId == this.props.roomId) { + this.setState({ + room: room + }); + } + + this._calculatePeekRules(room); + }, + + _calculatePeekRules: function(room) { + var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); + if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { + this.setState({ + guestsCanJoin: true + }); + } + + var historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", ""); + if (historyVisibility && historyVisibility.getContent().history_visibility === "world_readable") { + this.setState({ + canPeek: true + }); + } + }, + onRoomName: function(room) { if (room.roomId == this.props.roomId) { this.setState({ @@ -235,9 +313,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, + }); } }, @@ -249,6 +376,14 @@ module.exports = React.createClass({ if (member.roomId === this.props.roomId) { // a member state changed in this room, refresh the tab complete list this._updateTabCompleteList(this.state.room); + + var room = MatrixClientPeg.get().getRoom(this.props.roomId); + var me = MatrixClientPeg.get().credentials.userId; + if (this.state.joining && room.hasMembershipState(me, "join")) { + this.setState({ + joining: false + }); + } } if (!this.props.ConferenceHandler) { @@ -314,7 +449,24 @@ 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); + + // XXX: EVIL HACK to autofocus inviting on empty rooms. + // We use the setTimeout to avoid racing with focus_composer. + if (this.state.room && this.state.room.getJoinedMembers().length == 1) { + var inviteBox = document.getElementById("mx_SearchableEntityList_query"); + setTimeout(function() { + inviteBox.focus(); + }, 50); + } }, _updateTabCompleteList: function(room) { @@ -322,7 +474,9 @@ module.exports = React.createClass({ return; } this.tabComplete.setCompletionList( - MemberEntry.fromMemberList(room.getJoinedMembers()) + MemberEntry.fromMemberList(room.getJoinedMembers()).concat( + CommandEntry.fromCommands(SlashCommands.getCommandList()) + ) ); }, @@ -330,13 +484,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() { @@ -353,11 +504,14 @@ module.exports = React.createClass({ _paginateCompleted: function() { debuglog("paginate complete"); - this.setState({ - room: MatrixClientPeg.get().getRoom(this.props.roomId) - }); + // we might have switched rooms since the paginate started - just bin + // the results if so. + if (this.unmounted) return; - this.setState({paginating: false}); + this.setState({ + room: MatrixClientPeg.get().getRoom(this.props.roomId), + paginating: false, + }); }, onSearchResultsFillRequest: function(backwards) { @@ -412,16 +566,29 @@ module.exports = React.createClass({ onJoinButtonClicked: function(ev) { var self = this; - MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() { + MatrixClientPeg.get().joinRoom(this.props.roomId).done(function() { + // It is possible that there is no Room yet if state hasn't come down + // from /sync - joinRoom will resolve when the HTTP request to join succeeds, + // NOT when it comes down /sync. If there is no room, we'll keep the + // joining flag set until we see it. Likewise, if our state is not + // "join" we'll keep this flag set until it comes down /sync. + var room = MatrixClientPeg.get().getRoom(self.props.roomId); + var me = MatrixClientPeg.get().credentials.userId; self.setState({ - joining: false, - room: MatrixClientPeg.get().getRoom(self.props.roomId) + joining: room ? !room.hasMembershipState(me, "join") : true, + room: room }); }, function(error) { self.setState({ joining: false, joinError: error }); + var msg = error.message ? error.message : JSON.stringify(error); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to join room", + description: msg + }); }); this.setState({ joining: true @@ -535,7 +702,7 @@ module.exports = React.createClass({ return searchPromise.then(function(results) { debuglog("search complete"); - if (!self.state.searching || self.searchId != localSearchId) { + if (self.unmounted || !self.state.searching || self.searchId != localSearchId) { console.error("Discarding stale search results"); return; } @@ -553,7 +720,8 @@ module.exports = React.createClass({ // For overlapping highlights, // favour longer (more specific) terms first - highlights = highlights.sort(function(a, b) { b.length - a.length }); + highlights = highlights.sort(function(a, b) { + return b.length - a.length }); self.setState({ searchHighlights: highlights, @@ -648,9 +816,10 @@ module.exports = React.createClass({ var EventTile = sdk.getComponent('rooms.EventTile'); - var prevEvent = null; // the last event we showed 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]; @@ -664,6 +833,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) { @@ -700,13 +888,33 @@ module.exports = React.createClass({ ); + // 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; }, - uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels) { + uploadNewState: function(newVals) { var old_name = this.state.room.name; var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', ''); @@ -730,61 +938,206 @@ 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 != new_name && new_name != undefined && new_name) { + if (old_name != newVals.name && newVals.name != undefined) { deferreds.push( - MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name) + MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name) ); } - if (old_topic != new_topic && new_topic != undefined) { + if (old_topic != newVals.topic && newVals.topic != undefined) { deferreds.push( - MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic) + MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, newVals.topic) ); } - if (old_join_rule != new_join_rule && new_join_rule != undefined) { + if (old_join_rule != newVals.join_rule && newVals.join_rule != undefined) { deferreds.push( MatrixClientPeg.get().sendStateEvent( this.state.room.roomId, "m.room.join_rules", { - join_rule: new_join_rule, + join_rule: newVals.join_rule, }, "" ) ); } - if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) { - deferreds.push( + var visibilityDeferred; + if (old_history_visibility != newVals.history_visibility && + newVals.history_visibility != undefined) { + visibilityDeferred = MatrixClientPeg.get().sendStateEvent( this.state.room.roomId, "m.room.history_visibility", { - history_visibility: new_history_visibility, + history_visibility: newVals.history_visibility, }, "" + ); + } + + if (old_guest_read != newVals.guest_read || + old_guest_join != newVals.guest_join) + { + var guestDeferred = + MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { + allowRead: newVals.guest_read, + allowJoin: newVals.guest_join + }); + + if (visibilityDeferred) { + visibilityDeferred = visibilityDeferred.then(guestDeferred); + } + else { + visibilityDeferred = guestDeferred; + } + } + + if (visibilityDeferred) { + deferreds.push(visibilityDeferred); + } + + // setRoomMutePushRule will do nothing if there is no change + deferreds.push( + MatrixClientPeg.get().setRoomMutePushRule( + "global", this.state.room.roomId, newVals.are_notifications_muted + ) + ); + + if (newVals.power_levels) { + deferreds.push( + MatrixClientPeg.get().sendStateEvent( + this.state.room.roomId, "m.room.power_levels", newVals.power_levels, "" ) ); } - if (new_power_levels) { + 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.power_levels", new_power_levels, "" + 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 (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, @@ -815,8 +1168,15 @@ module.exports = React.createClass({ var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn(); if (lastReadEventIndex === null) return; - if (lastReadEventIndex > currentReadUpToEventIndex) { - MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]); + var lastReadEvent = this.state.room.timeline[lastReadEventIndex]; + + // we also remember the last read receipt we sent to avoid spamming the same one at the server repeatedly + if (lastReadEventIndex > currentReadUpToEventIndex && this.last_rr_sent_event_id != lastReadEvent.getId()) { + this.last_rr_sent_event_id = lastReadEvent.getId(); + MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => { + // it failed, so allow retries next time the user is active + this.last_rr_sent_event_id = undefined; + }); } }, @@ -847,31 +1207,32 @@ module.exports = React.createClass({ }, onSettingsClick: function() { - this.setState({editingRoomSettings: true}); + this.showSettings(true); }, onSaveClick: function() { this.setState({ - editingRoomSettings: false, uploadingRoomSettings: true, }); - var new_name = this.refs.header.getRoomName(); - var new_topic = this.refs.room_settings.getTopic(); - var new_join_rule = this.refs.room_settings.getJoinRules(); - var new_history_visibility = this.refs.room_settings.getHistoryVisibility(); - var new_power_levels = this.refs.room_settings.getPowerLevels(); - - this.uploadNewState( - new_name, - new_topic, - new_join_rule, - new_history_visibility, - new_power_levels - ); + this.uploadNewState({ + name: this.refs.header.getRoomName(), + 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(), + color_scheme: this.refs.room_settings.getColorScheme(), + }); }, onCancelClick: function() { + this.updateTint(); this.setState({editingRoomSettings: false}); }, @@ -1019,20 +1380,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 + (this.state.editingRoomSettings ? (window.innerHeight * 0.3) : 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) { - // XXX: don't understand why we have to call findDOMNode here in react 0.14 - it should already be a DOM node. - var video = ReactDOM.findDOMNode(this.refs.callView.refs.video.refs.remote); - - // header + footer + status + give us at least 100px of scrollback at all times. - auxPanelMaxHeight = window.innerHeight - (83 + 72 + 36 + 100); - - // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway - // but it's better than the video going missing entirely - if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; + var video = this.refs.callView.getVideoView().getRemoteVideoElement(); video.style.maxHeight = auxPanelMaxHeight + "px"; } + + // we need to do this for general auxPanels too + if (this.refs.auxPanel) { + this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px"; + } + + // the above might have made the aux panel resize itself, so now + // we need to tell the gemini panel to adapt. + this.onChildResize(); }, onFullscreenClick: function() { @@ -1066,6 +1439,24 @@ module.exports = React.createClass({ }); }, + onChildResize: function() { + // When the video or the message composer resizes, the scroll panel + // also changes size. Work around GeminiScrollBar fail by telling it + // about it. This also ensures that the scroll offset is updated. + if (this.refs.messagePanel) { + this.refs.messagePanel.forceUpdate(); + } + }, + + showSettings: function(show) { + // XXX: this is a bit naughty; we should be doing this via props + if (show) { + this.setState({editingRoomSettings: true}); + var self = this; + setTimeout(function() { self.onResize() }, 0); + } + }, + render: function() { var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var MessageComposer = sdk.getComponent('rooms.MessageComposer'); @@ -1073,15 +1464,35 @@ module.exports = React.createClass({ var RoomSettings = sdk.getComponent("rooms.RoomSettings"); 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) { - return ( -
    - -
    - ); - } else { + if (this.props.autoPeek && !this.state.autoPeekDone) { + var Loader = sdk.getComponent("elements.Spinner"); + return ( +
    + +
    + ); + } + else { + var joinErrorText = this.state.joinError ? "Failed to join room!" : ""; + return ( +
    + +
    + +
    {joinErrorText}
    +
    +
    +
    + ); + } + } + else { return (
    ); @@ -1102,19 +1513,26 @@ module.exports = React.createClass({ var inviteEvent = myMember.events.member; var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender(); // XXX: Leaving this intentionally basic for now because invites are about to change totally + // FIXME: This comment is now outdated - what do we need to fix? ^ var joinErrorText = this.state.joinError ? "Failed to join room!" : ""; var rejectErrorText = this.state.rejectError ? "Failed to reject invite!" : ""; + + // We deliberately don't try to peek into invites, even if we have permission to peek + // as they could be a spam vector. + // XXX: in future we could give the option of a 'Preview' button which lets them view anyway. + return (
    - -
    -
    {inviterName} has invited you to a room
    -
    - - + +
    +
    {joinErrorText}
    {rejectErrorText}
    +
    ); } @@ -1147,7 +1565,7 @@ module.exports = React.createClass({ if (this.state.syncState === "ERROR") { statusBar = (
    - /!\ + /!\
    Connectivity to the server has been lost. @@ -1166,8 +1584,8 @@ module.exports = React.createClass({
    ...
    -
    - ->| +
    + Auto-complete
    @@ -1177,7 +1595,7 @@ module.exports = React.createClass({ else if (this.state.hasUnsentMessages) { statusBar = (
    - /!\ + /!\
    Some of your messages have not been sent. @@ -1214,7 +1632,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"); @@ -1223,6 +1641,18 @@ module.exports = React.createClass({ else if (this.state.searching) { aux = ; } + else if (this.state.guestsCanJoin && MatrixClientPeg.get().isGuest() && + (!myMember || myMember.membership !== "join")) { + aux = ( + + ); + } + else if (this.state.canPeek && + (!myMember || myMember.membership !== "join")) { + aux = ( + + ); + } var conferenceCallNotification = null; if (this.state.displayConfCallNotification) { @@ -1240,9 +1670,9 @@ module.exports = React.createClass({ var fileDropTarget = null; if (this.state.draggingFile) { fileDropTarget =
    -
    - Drop File Here
    - Drop File Here +
    +
    + Drop file here to upload
    ; } @@ -1255,7 +1685,7 @@ module.exports = React.createClass({ if (canSpeak) { messageComposer = } @@ -1278,25 +1708,29 @@ module.exports = React.createClass({ if (call.type === "video") { zoomButton = ( -
    - Fill screen +
    +
    ); videoMuteButton =
    - + {call.isLocalVideoMuted()
    } voiceMuteButton =
    - + {call.isMicrophoneMuted()
    if (!statusBar) { statusBar =
    - + Active call
    ; } @@ -1307,11 +1741,10 @@ module.exports = React.createClass({ { videoMuteButton } { zoomButton } { statusBar } - +
    } - // if we have search results, we keep the messagepanel (so that it preserves its // scroll state), but hide it. var searchResultsPanel; @@ -1339,7 +1772,7 @@ module.exports = React.createClass({ ); return ( -
    +
    - { fileDropTarget } -
    - +
    + { fileDropTarget } + { conferenceCallNotification } { aux }
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 042458717d..8d26b2e365 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -112,6 +112,14 @@ module.exports = React.createClass({ this.checkFillState(); }, + componentWillUnmount: function() { + // set a boolean to say we've been unmounted, which any pending + // promises can use to throw away their results. + // + // (We could use isMounted(), but facebook have deprecated that.) + this.unmounted = true; + }, + onScroll: function(ev) { var sn = this._getScrollNode(); debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); @@ -158,6 +166,10 @@ module.exports = React.createClass({ // check the scroll state and send out backfill requests if necessary. checkFillState: function() { + if (this.unmounted) { + return; + } + var sn = this._getScrollNode(); // if there is less than a screenful of messages above or below the @@ -346,6 +358,12 @@ module.exports = React.createClass({ * message panel. */ _getScrollNode: function() { + if (this.unmounted) { + // this shouldn't happen, but when it does, turn the NPE into + // something more meaningful. + throw new Error("ScrollPanel._getScrollNode called when unmounted"); + } + var panel = ReactDOM.findDOMNode(this.refs.geminiPanel); // If the gemini scrollbar is doing its thing, this will be a div within 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 c1550f9b6b..d1bb66356c 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ var React = require('react'); +var ReactDOM = require('react-dom'); var sdk = require('../../index'); var MatrixClientPeg = require("../../MatrixClientPeg"); var Modal = require('../../Modal'); @@ -21,6 +22,9 @@ var dis = require("../../dispatcher"); var q = require('q'); var version = require('../../../package.json').version; var UserSettingsStore = require('../../UserSettingsStore'); +var GeminiScrollbar = require('react-gemini-scrollbar'); +var Email = require('../../email'); +var AddThreepid = require('../../AddThreepid'); module.exports = React.createClass({ displayName: 'UserSettings', @@ -41,6 +45,7 @@ module.exports = React.createClass({ threePids: [], clientVersion: version, phase: "UserSettings.LOADING", // LOADING, DISPLAY + email_add_pending: false, }; }, @@ -83,6 +88,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; @@ -135,6 +146,12 @@ module.exports = React.createClass({ }); }, + onUpgradeClicked: function() { + dis.dispatch({ + action: "start_upgrade_registration" + }); + }, + onLogoutPromptCancel: function() { this.logoutModal.closeDialog(); }, @@ -143,10 +160,81 @@ module.exports = React.createClass({ UserSettingsStore.setEnableNotifications(event.target.checked); }, + onAddThreepidClicked: function(value, shouldSubmit) { + if (!shouldSubmit) return; + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + + var email_address = this.refs.add_threepid_input.value; + if (!Email.looksValid(email_address)) { + Modal.createDialog(ErrorDialog, { + title: "Invalid Email Address", + description: "This doesn't appear to be a valid email address", + }); + return; + } + this.add_threepid = new AddThreepid(); + // we always bind emails when registering, so let's do the + // same here. + this.add_threepid.addEmailAddress(email_address, true).done(() => { + Modal.createDialog(QuestionDialog, { + title: "Verification Pending", + description: "Please check your email and click on the link it contains. Once this is done, click continue.", + button: 'Continue', + onFinished: this.onEmailDialogFinished, + }); + }, (err) => { + Modal.createDialog(ErrorDialog, { + title: "Unable to add email address", + description: err.toString() + }); + }); + ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); + this.setState({email_add_pending: true}); + }, + + onEmailDialogFinished: function(ok) { + if (ok) { + this.verifyEmailAddress(); + } else { + this.setState({email_add_pending: false}); + } + }, + + verifyEmailAddress: function() { + this.add_threepid.checkEmailLinkClicked().done(() => { + this.add_threepid = undefined; + this.setState({ + phase: "UserSettings.LOADING", + }); + this._refreshFromServer(); + this.setState({email_add_pending: false}); + }, (err) => { + if (err.errcode == 'M_THREEPID_AUTH_FAILED') { + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + var message = "Unable to verify email address. " + message += "Please check your email and click on the link it contains. Once this is done, click continue." + Modal.createDialog(QuestionDialog, { + title: "Verification Pending", + description: message, + button: 'Continue', + onFinished: this.onEmailDialogFinished, + }); + } else { + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Unable to verify email address", + description: err.toString(), + }); + } + }); + }, + render: function() { + var self = this; + var Loader = sdk.getComponent("elements.Spinner"); switch (this.state.phase) { case "UserSettings.LOADING": - var Loader = sdk.getComponent("elements.Spinner"); return ( ); @@ -160,14 +248,76 @@ module.exports = React.createClass({ var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName"); var ChangePassword = sdk.getComponent("views.settings.ChangePassword"); var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); + var Notifications = sdk.getComponent("settings.Notifications"); + var EditableText = sdk.getComponent('elements.EditableText'); var avatarUrl = ( this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null ); + var threepidsSection = this.state.threepids.map(function(val, pidIndex) { + var id = "email-" + val.address; + return ( +
    +
    + +
    +
    + +
    +
    + ); + }); + var addThreepidSection; + if (this.state.email_add_pending) { + addThreepidSection = ; + } else { + addThreepidSection = ( +
    +
    +
    + +
    + Add +
    +
    + ); + } + threepidsSection.push(addThreepidSection); + + var accountJsx; + + if (MatrixClientPeg.get().isGuest()) { + accountJsx = ( +
    + Create an account +
    + ); + } + else { + accountJsx = ( + + ); + } + return (
    + +

    Profile

    @@ -180,30 +330,19 @@ module.exports = React.createClass({
    - - {this.state.threepids.map(function(val, pidIndex) { - var id = "email-" + val.address; - return ( -
    -
    - -
    -
    - -
    -
    - ); - })} + {threepidsSection}
    - +
    + +
    -
    @@ -213,41 +352,18 @@ module.exports = React.createClass({

    Account

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

    Notifications

    -
    -
    -
    - -
    -
    - -
    -
    -
    +

    Advanced

    @@ -260,6 +376,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 7f6e408fef..7b2808c72a 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -19,7 +19,6 @@ limitations under the License. var React = require('react'); var sdk = require('../../../index'); -var MatrixClientPeg = require('../../../MatrixClientPeg'); var dis = require('../../../dispatcher'); var Signup = require("../../../Signup"); var ServerConfig = require("../../views/login/ServerConfig"); @@ -40,6 +39,9 @@ module.exports = React.createClass({ hsUrl: React.PropTypes.string, isUrl: React.PropTypes.string, email: React.PropTypes.string, + username: React.PropTypes.string, + guestAccessToken: React.PropTypes.string, + disableUsernameChanges: React.PropTypes.bool, // registration shouldn't know or care how login is done. onLoginClick: React.PropTypes.func.isRequired }, @@ -63,6 +65,7 @@ module.exports = React.createClass({ this.registerLogic.setSessionId(this.props.sessionId); this.registerLogic.setRegistrationUrl(this.props.registrationUrl); this.registerLogic.setIdSid(this.props.idSid); + this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); this.registerLogic.recheckState(); }, @@ -156,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."; @@ -186,7 +198,9 @@ module.exports = React.createClass({ registerStep = ( 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/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index ed9364df60..d06cf2de84 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -48,7 +48,7 @@ module.exports = React.createClass({ render: function() { return (
    -
    +
    {this.props.title}
    diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index f415201e45..4eeecd64b3 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -46,7 +46,7 @@ module.exports = React.createClass({ render: function() { return (
    -
    +
    {this.props.title}
    diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js new file mode 100644 index 0000000000..3cda852449 --- /dev/null +++ b/src/components/views/dialogs/TextInputDialog.js @@ -0,0 +1,94 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var React = require("react"); + +module.exports = React.createClass({ + displayName: 'TextInputDialog', + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.string, + value: React.PropTypes.string, + button: React.PropTypes.string, + focus: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired + }, + + getDefaultProps: function() { + return { + title: "", + value: "", + description: "", + button: "OK", + focus: true + }; + }, + + componentDidMount: function() { + if (this.props.focus) { + // Set the cursor at the end of the text input + this.refs.textinput.value = this.props.value; + } + }, + + onOk: function() { + this.props.onFinished(true, this.refs.textinput.value); + }, + + onCancel: function() { + this.props.onFinished(false); + }, + + onKeyDown: function(e) { + if (e.keyCode === 27) { // escape + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(false); + } + else if (e.keyCode === 13) { // enter + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(true, this.refs.textinput.value); + } + }, + + render: function() { + return ( +
    +
    + {this.props.title} +
    +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    + ); + } +}); 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/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js new file mode 100644 index 0000000000..81fbed1921 --- /dev/null +++ b/src/components/views/elements/TintableSvg.js @@ -0,0 +1,69 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var React = require('react'); +var ReactDOM = require("react-dom"); +var dis = require("../../../dispatcher"); +var Tinter = require("../../../Tinter"); + +module.exports = React.createClass({ + displayName: 'TintableSvg', + + propTypes: { + src: React.PropTypes.string.isRequired, + width: React.PropTypes.string.isRequired, + height: React.PropTypes.string.isRequired, + className: React.PropTypes.string, + }, + + componentWillMount: function() { + this.fixups = []; + this.dispatcherRef = dis.register(this.onAction); + }, + + componentDidMount: function() { + // we can't use onLoad on object due to https://github.com/facebook/react/pull/5781 + // so handle it with pure DOM instead + ReactDOM.findDOMNode(this).addEventListener('load', this.onLoad); + }, + + componentWillUnmount: function() { + ReactDOM.findDOMNode(this).removeEventListener('load', this.onLoad); + dis.unregister(this.dispatcherRef); + }, + + onAction: function(payload) { + if (payload.action !== 'tint_update') return; + Tinter.applySvgFixups(this.fixups); + }, + + onLoad: function(event) { + this.fixups = Tinter.calcSvgFixups([event.target]); + Tinter.applySvgFixups(this.fixups); + }, + + render: function() { + return ( + + ); + } +}); diff --git a/src/components/views/login/CustomServerDialog.js b/src/components/views/login/CustomServerDialog.js index 8a67dfd7e6..dc6a49abd6 100644 --- a/src/components/views/login/CustomServerDialog.js +++ b/src/components/views/login/CustomServerDialog.js @@ -22,7 +22,7 @@ module.exports = React.createClass({ render: function() { return (
    -
    +
    Custom Server Options
    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 bc461dd1bb..469deb890a 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -17,7 +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 Email = require('../../../email'); + +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. @@ -30,6 +38,7 @@ module.exports = React.createClass({ defaultUsername: React.PropTypes.string, showEmail: React.PropTypes.bool, minPasswordLength: React.PropTypes.number, + disableUsernameChanges: React.PropTypes.bool, onError: React.PropTypes.func, onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise }, @@ -49,52 +58,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 == '' || Email.looksValid(this.refs.email.value), + "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) { @@ -109,13 +217,20 @@ module.exports = React.createClass({ {emailSection}
    + placeholder="User name" defaultValue={this.state.username} + style={this._styleField(FIELD_USERNAME)} + onBlur={function() {self.validateField(FIELD_USERNAME)}} + disabled={this.props.disableUsernameChanges} />


    {registerButton} diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 2be62a12d7..2f416daf95 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -19,6 +19,8 @@ limitations under the License. var React = require('react'); var filesize = require('filesize'); var MatrixClientPeg = require('../../../MatrixClientPeg'); +var sdk = require('../../../index'); +var dis = require("../../../dispatcher"); module.exports = React.createClass({ displayName: 'MFileBody', @@ -52,12 +54,14 @@ module.exports = React.createClass({ var httpUrl = cli.mxcUrlToHttp(content.url); var text = this.presentableTextForFile(content); + var TintableSvg = sdk.getComponent("elements.TintableSvg"); + if (httpUrl) { return ( diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 483e38a6c4..691380d678 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -22,6 +22,7 @@ var filesize = require('filesize'); var MatrixClientPeg = require('../../../MatrixClientPeg'); var Modal = require('../../../Modal'); var sdk = require('../../../index'); +var dis = require("../../../dispatcher"); module.exports = React.createClass({ displayName: 'MImageBody', @@ -97,6 +98,7 @@ module.exports = React.createClass({ }, render: function() { + var TintableSvg = sdk.getComponent("elements.TintableSvg"); var content = this.props.mxEvent.getContent(); var cli = MatrixClientPeg.get(); @@ -118,7 +120,7 @@ module.exports = React.createClass({ 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/EntityTile.js b/src/components/views/rooms/EntityTile.js new file mode 100644 index 0000000000..ed0e5cbc41 --- /dev/null +++ b/src/components/views/rooms/EntityTile.js @@ -0,0 +1,139 @@ +/* +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'); + + +var PRESENCE_CLASS = { + "offline": "mx_EntityTile_offline", + "online": "mx_EntityTile_online", + "unavailable": "mx_EntityTile_unavailable" +}; + +module.exports = React.createClass({ + displayName: 'EntityTile', + + propTypes: { + name: React.PropTypes.string, + title: React.PropTypes.string, + avatarJsx: React.PropTypes.any, // + presenceState: React.PropTypes.string, + presenceActiveAgo: React.PropTypes.number, + showInviteButton: React.PropTypes.bool, + shouldComponentUpdate: React.PropTypes.func, + onClick: React.PropTypes.func + }, + + getDefaultProps: function() { + return { + shouldComponentUpdate: function(nextProps, nextState) { return false; }, + onClick: function() {}, + presenceState: "offline", + presenceActiveAgo: -1, + showInviteButton: false, + }; + }, + + getInitialState: function() { + return { + hover: false + }; + }, + + shouldComponentUpdate: function(nextProps, nextState) { + if (this.state.hover !== nextState.hover) return true; + return this.props.shouldComponentUpdate(nextProps, nextState); + }, + + mouseEnter: function(e) { + this.setState({ 'hover': true }); + }, + + mouseLeave: function(e) { + this.setState({ 'hover': false }); + }, + + render: function() { + var presenceClass = PRESENCE_CLASS[this.props.presenceState] || "mx_EntityTile_offline"; + var mainClassName = "mx_EntityTile "; + mainClassName += presenceClass; + if (this.state.hover) { + mainClassName += " mx_EntityTile_hover"; + } + + var nameEl; + if (this.state.hover) { + var PresenceLabel = sdk.getComponent("rooms.PresenceLabel"); + nameEl = ( +
    + +
    { this.props.name }
    + +
    + ); + } + else { + nameEl = ( +
    + { this.props.name } +
    + ); + } + + var inviteButton; + if (this.props.showInviteButton) { + inviteButton = ( +
    + +
    + ); + } + + var power; + var powerLevel = this.props.powerLevel; + if (powerLevel >= 50 && powerLevel < 99) { + power = Mod; + } + if (powerLevel >= 99) { + power = Admin; + } + + + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + + var av = this.props.avatarJsx || ; + + return ( +
    +
    + { av } + { power } +
    + { nameEl } + { inviteButton } +
    + ); + } +}); 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..a3e0ee4555 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -15,12 +15,20 @@ 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 Entities = require("../../../Entities"); 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', @@ -63,8 +71,13 @@ module.exports = React.createClass({ self.setState({ members: self.roomMembers() }); + // lazy load to prevent it blocking the first render + self._loadUserList(); }, 50); + + setTimeout + // Attach a SINGLE listener for global presence changes then locate the // member tile and re-render it. This is more efficient than every tile // evar attaching their own listener. @@ -88,6 +101,21 @@ module.exports = React.createClass({ /*componentWillReceiveProps: function(newProps) { },*/ + _loadUserList: function() { + var room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!room) { + return; // we'll do it later + } + + // Load the complete user list for inviting new users + // TODO: Keep this list bleeding-edge up-to-date. Practically speaking, + // it will do for now not being updated as random new users join different + // rooms as this list will be reloaded every room swap. + this.userList = MatrixClientPeg.get().getUsers().filter(function(u) { + return !room.hasMembershipState(u.userId, "join"); + }); + }, + onRoom: function(room) { if (room.roomId !== this.props.roomId) { return; @@ -96,6 +124,7 @@ module.exports = React.createClass({ // we need to wait till the room is fully populated with state // before refreshing the member list else we get a stale list. this._updateList(); + this._loadUserList(); }, onRoomStateMember: function(ev, state, member) { @@ -131,12 +160,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 +209,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 }); @@ -225,12 +285,23 @@ module.exports = React.createClass({ return latB - latA; }, - makeMemberTiles: function(membership) { + onSearchQueryChanged: function(input) { + this.setState({ + searchQuery: input + }); + }, + + makeMemberTiles: function(membership, query) { var MemberTile = sdk.getComponent("rooms.MemberTile"); + query = (query || "").toLowerCase(); var self = this; - return self.state.members.filter(function(userId) { + + var memberList = self.state.members.filter(function(userId) { var m = self.memberDict[userId]; + if (query && m.name.toLowerCase().indexOf(query) !== 0) { + return false; + } return m.membership == membership; }).map(function(userId) { var m = self.memberDict[userId]; @@ -238,11 +309,32 @@ module.exports = React.createClass({ ); }); - }, - onPopulateInvite: function(e) { - this.onInvite(this.refs.invite.value); - e.preventDefault(); + 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); + var EntityTile = sdk.getComponent("rooms.EntityTile"); + 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; }, inviteTile: function() { @@ -252,22 +344,25 @@ module.exports = React.createClass({ ); } else { + var SearchableEntityList = sdk.getComponent("rooms.SearchableEntityList"); + return ( -
    - -
    + ); } }, render: function() { var invitedSection = null; - var invitedMemberTiles = this.makeMemberTiles('invite'); + var invitedMemberTiles = this.makeMemberTiles('invite', this.state.searchQuery); if (invitedMemberTiles.length > 0) { invitedSection = (

    Invited

    -
    +
    {invitedMemberTiles}
    @@ -275,15 +370,17 @@ module.exports = React.createClass({ } return (
    - {this.inviteTile()} -
    +
    - {this.makeMemberTiles('join')} + {this.makeMemberTiles('join', this.state.searchQuery)} +
    + {invitedSection} +
    +
    +
    - {invitedSection} -
    ); } diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index 32cc619f13..d5c124ad21 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -26,25 +26,20 @@ var Modal = require("../../../Modal"); module.exports = React.createClass({ displayName: 'MemberTile', + propTypes: { + member: React.PropTypes.any.isRequired, // RoomMember + }, + 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.member_last_modified_time === undefined || this.member_last_modified_time < nextProps.member.getLastModifiedTime() ) { - return true + return true; } if ( nextProps.member.user && @@ -56,14 +51,6 @@ module.exports = React.createClass({ return false; }, - mouseEnter: function(e) { - this.setState({ 'hover': true }); - }, - - mouseLeave: function(e) { - this.setState({ 'hover': false }); - }, - onClick: function(e) { dis.dispatch({ action: 'view_user', @@ -71,114 +58,43 @@ module.exports = React.createClass({ }); }, - 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(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"; + _getDisplayName: function() { + return this.props.member.name; }, getPowerLabel: function() { - var label = this.props.member.userId; - if (this.state.isTargetMod) { - label += " - Mod (" + this.props.member.powerLevelNorm + "%)"; - } - return label; + return this.props.member.userId + " (power " + this.props.member.powerLevel + ")"; }, 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 presenceClass = "mx_MemberTile_offline"; - var mainClassName = "mx_MemberTile "; - if (this.props.member.user) { - if (this.props.member.user.presence === "online") { - presenceClass = "mx_MemberTile_online"; - } - else if (this.props.member.user.presence === "unavailable") { - presenceClass = "mx_MemberTile_unavailable"; - } - } - 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 = -
    - -
    { name }
    - { presence } -
    - } - else { - nameEl = -
    - { name } -
    - } - var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + var EntityTile = sdk.getComponent('rooms.EntityTile'); + + var member = this.props.member; + var name = this._getDisplayName(); + var active = -1; + var presenceState = member.user ? member.user.presence : null; + + var av = ( + + ); + + 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 + ); + } + this.member_last_modified_time = member.getLastModifiedTime(); + return ( -
    -
    - - { power } -
    - { nameEl } -
    + ); } }); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index bc740dd6d0..d5aaaa1128 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -65,8 +65,17 @@ function mdownToHtml(mdown) { module.exports = React.createClass({ displayName: 'MessageComposer', + statics: { + // the height we limit the composer to + MAX_HEIGHT: 100, + }, + propTypes: { - tabComplete: React.PropTypes.any + tabComplete: React.PropTypes.any, + + // a callback which is called when the height of the composer is + // changed due to a change in content. + onResize: React.PropTypes.func, }, componentWillMount: function() { @@ -200,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) { @@ -237,13 +241,15 @@ module.exports = React.createClass({ // scrollHeight is at least equal to clientHeight, so we have to // temporarily crimp clientHeight to 0 to get an accurate scrollHeight value this.refs.textarea.style.height = "0px"; - var newHeight = this.refs.textarea.scrollHeight < 100 ? this.refs.textarea.scrollHeight : 100; + var newHeight = Math.min(this.refs.textarea.scrollHeight, + this.constructor.MAX_HEIGHT); this.refs.textarea.style.height = Math.ceil(newHeight) + "px"; - if (this.props.roomView) { - // kick gemini-scrollbar to re-layout - this.props.roomView.forceUpdate(); - } this.oldScrollHeight = this.refs.textarea.scrollHeight; + + if (this.props.onResize) { + // kick gemini-scrollbar to re-layout + this.props.onResize(); + } }, onKeyUp: function(ev) { @@ -330,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' }); @@ -461,6 +467,7 @@ module.exports = React.createClass({ var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + var TintableSvg = sdk.getComponent("elements.TintableSvg"); var callButton, videoCallButton, hangupButton; var call = CallHandler.getCallForRoom(this.props.room.roomId); @@ -473,12 +480,12 @@ module.exports = React.createClass({ } else { callButton = -
    - Voice call +
    +
    videoCallButton = -
    - Video call +
    +
    } @@ -492,8 +499,8 @@ module.exports = React.createClass({
    - 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 !== 'Empty room') { + 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. @@ -114,39 +218,93 @@ module.exports = React.createClass({ searchStatus =
     (~{ this.props.searchInfo.searchCount } results)
    ; } + // XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'... + var members = this.props.room.getJoinedMembers(); + var settingsHint = false; + if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { + var name = this.props.room.currentState.getStateEvents('m.room.name', ''); + if (!name || !name.getContent().name) { + settingsHint = true; + } + } + name =
    -
    { this.props.room.name }
    +
    { this.props.room.name }
    { searchStatus } -
    - +
    +
    - 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; if (this.props.onLeaveClick) { leave_button = -
    - Leave room +
    +
    ; } var forget_button; if (this.props.onForgetClick) { forget_button = -
    - Forget room +
    + +
    ; + } + + var right_row; + if (!this.props.editing) { + right_row = +
    + { forget_button } + { leave_button } +
    + +
    ; } @@ -161,20 +319,14 @@ module.exports = React.createClass({ { topic_el }
    - {cancel_button} {save_button} -
    - { forget_button } - { leave_button } -
    - Search -
    -
    + {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..52e6639f13 --- /dev/null +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -0,0 +1,83 @@ +/* +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, + onRejectClick: React.PropTypes.func, + inviterName: React.PropTypes.string, + canJoin: React.PropTypes.bool, + canPreview: React.PropTypes.bool, + }, + + getDefaultProps: function() { + return { + onJoinClick: function() {}, + canJoin: false, + canPreview: true, + }; + }, + + render: function() { + var joinBlock, previewBlock; + + if (this.props.inviterName) { + joinBlock = ( +
    +
    + You have been invited to join this room by { this.props.inviterName } +
    +
    + Would you like to accept or decline this invitation? +
    +
    + ); + + } + else if (this.props.canJoin) { + joinBlock = ( +
    +
    + Would you like to join this room? +
    +
    + ); + } + + if (this.props.canPreview) { + previewBlock = ( +
    + This is a preview of this room. Room interactions have been disabled. +
    + ); + } + + return ( +
    +
    + { joinBlock } + { previewBlock } +
    +
    + ); + } +}); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 211ecbd71a..53dedbe812 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -16,23 +16,100 @@ 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, }, 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; + }, + + canGuestsRead: function() { + return this.refs.guests_read.checked; + }, + getTopic: function() { - return this.refs.topic.value; + return this.refs.topic ? this.refs.topic.value : ""; }, getJoinRules: function() { @@ -43,6 +120,10 @@ module.exports = React.createClass({ return this.refs.share_history.checked ? "shared" : "invited"; }, + areNotificationsMuted: function() { + return this.refs.are_notifications_muted.checked; + }, + getPowerLevels: function() { if (!this.state.power_levels_changed) return undefined; @@ -50,13 +131,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, }; @@ -64,17 +145,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 address format", + description: "'" + alias + "' is not a valid format for an address", + }); + } + }, + + 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; @@ -83,8 +378,23 @@ module.exports = React.createClass({ if (history_visibility) history_visibility = history_visibility.getContent().history_visibility; var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); + var guest_access = this.props.room.currentState.getStateEvents('m.room.guest_access', ''); + if (guest_access) { + guest_access = guest_access.getContent().guest_access; + } + + var are_notifications_muted; + var roomPushRule = MatrixClientPeg.get().getRoomPushRule("global", this.props.room.roomId); + if (roomPushRule) { + if (0 <= roomPushRule.actions.indexOf("dont_notify")) { + are_notifications_muted = true; + } + } + + var events_levels = (power_levels ? power_levels.events : {}) || {}; + + var user_id = MatrixClientPeg.get().credentials.userId; - var events_levels = power_levels.events || {}; if (power_levels) { power_levels = power_levels.getContent(); @@ -103,8 +413,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; @@ -133,104 +441,299 @@ 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 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 = +
    +
    + Remote addresses for this room: +
    +
    + { remote_domains.map(function(state_key, i) { + return 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 = +
    +

    Addresses

    +
    The main address for this room is: { canonical_alias_section }
    +
    + { this.state.aliases[domain].length + ? "Local addresses for this room:" + : "This room has no local addresses" } +
    +
    + { this.state.aliases[domain].map(function(alias, i) { + var deleteButton; + if (can_set_room_aliases) { + deleteButton = Delete; + } + return ( +
    + +
    + { deleteButton } +
    +
    + ); + })} + +
    + +
    + Add +
    +
    +
    + + { remote_aliases_section } - var change_avatar; - if (can_set_room_avatar) { - change_avatar =
    -

    Room Icon

    -
    ; + + 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 (Object.keys(user_levels).length) { + user_levels_section = +
    +

    Privileged Users

    +
      + {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 = +
    + Tagged as: + { can_set_tag ? + tags.map(function(tag, i) { + return (); + }) : tags.map(function(tag) { return tag.label; }).join(", ") + } +
    + + // FIXME: disable guests_read if the user hasn't turned on shared history return (
    -