Merge branch 'develop' into push-rules-settings

# Conflicts:
#	src/component-index.js
#	src/components/views/rooms/RoomSettings.js
This commit is contained in:
manuroe 2016-01-18 18:00:41 +01:00
commit 33edeccb43
40 changed files with 2772 additions and 711 deletions

View file

@ -34,7 +34,8 @@
"react-dom": "^0.14.2", "react-dom": "^0.14.2",
"react-gemini-scrollbar": "^2.0.1", "react-gemini-scrollbar": "^2.0.1",
"sanitize-html": "^1.11.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", "//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", "//depsbuglink": "https://github.com/webpack/webpack/issues/1472",

104
src/PasswordReset.js Normal file
View file

@ -0,0 +1,104 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var Matrix = require("matrix-js-sdk");
/**
* Allows a user to reset their password on a homeserver.
*
* This involves getting an email token from the identity server to "prove" that
* the client owns the given email address, which is then passed to the password
* API on the homeserver in question with the new password.
*/
class PasswordReset {
/**
* Configure the endpoints for password resetting.
* @param {string} homeserverUrl The URL to the HS which has the account to reset.
* @param {string} identityUrl The URL to the IS which has linked the email -> mxid mapping.
*/
constructor(homeserverUrl, identityUrl) {
this.client = Matrix.createClient({
baseUrl: homeserverUrl,
idBaseUrl: identityUrl
});
this.clientSecret = generateClientSecret();
this.identityServerDomain = identityUrl.split("://")[1];
}
/**
* Attempt to reset the user's password. This will trigger a side-effect of
* sending an email to the provided email address.
* @param {string} emailAddress The email address
* @param {string} newPassword The new password for the account.
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
resetPassword(emailAddress, newPassword) {
this.password = newPassword;
return this.client.requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
}
/**
* Checks if the email link has been clicked by attempting to change the password
* for the mxid linked to the email.
* @return {Promise} Resolves if the password was reset. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
*/
checkEmailLinkClicked() {
return this.client.setPassword({
type: "m.login.email.identity",
threepid_creds: {
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: this.identityServerDomain
}
}, this.password).catch(function(err) {
if (err.httpStatus === 401) {
err.message = "Failed to verify email address: make sure you clicked the link in the email";
}
else if (err.httpStatus === 404) {
err.message = "Your email address does not appear to be associated with a Matrix ID on this Homeserver.";
}
else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`;
}
throw err;
});
}
}
// from Angular SDK
function generateClientSecret() {
var ret = "";
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (var i = 0; i < 32; i++) {
ret += chars.charAt(Math.floor(Math.random() * chars.length));
}
return ret;
}
module.exports = PasswordReset;

View file

@ -152,6 +152,8 @@ class Register extends Signup {
} else { } else {
if (error.errcode === 'M_USER_IN_USE') { if (error.errcode === 'M_USER_IN_USE') {
throw new Error("Username 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) { } else if (error.httpStatus == 401) {
throw new Error("Authorisation failed!"); throw new Error("Authorisation failed!");
} else if (error.httpStatus >= 400 && error.httpStatus < 500) { } else if (error.httpStatus >= 400 && error.httpStatus < 500) {

View file

@ -20,6 +20,31 @@ var dis = require("./dispatcher");
var encryption = require("./encryption"); var encryption = require("./encryption");
var Tinter = require("./Tinter"); 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) { var reject = function(msg) {
return { return {
error: msg error: msg
@ -34,22 +59,37 @@ var success = function(promise) {
var commands = { var commands = {
// Change your nickname // Change your nickname
nick: function(room_id, args) { nick: new Command("nick", "<display_name>", function(room_id, args) {
if (args) { if (args) {
return success( return success(
MatrixClientPeg.get().setDisplayName(args) MatrixClientPeg.get().setDisplayName(args)
); );
} }
return reject("Usage: /nick <display_name>"); return reject(this.getUsage());
}, }),
// Takes an #rrggbb colourcode and retints the UI (just for debugging) // Changes the colorscheme of your current room
tint: function(room_id, args) { tint: new Command("tint", "<color1> [<color2>]", function(room_id, args) {
Tinter.tint(args); if (args) {
return success(); var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/);
}, if (matches) {
Tinter.tint(matches[1], matches[4]);
var colorScheme = {}
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
}
return success(
MatrixClientPeg.get().setRoomAccountData(
room_id, "org.matrix.room.color_scheme", colorScheme
)
);
}
}
return reject(this.getUsage());
}),
encrypt: function(room_id, args) { encrypt: new Command("encrypt", "<on|off>", function(room_id, args) {
if (args == "on") { if (args == "on") {
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
var members = client.getRoom(room_id).currentState.members; var members = client.getRoom(room_id).currentState.members;
@ -65,21 +105,21 @@ var commands = {
); );
} }
return reject("Usage: encrypt <on/off>"); return reject(this.getUsage());
}, }),
// Change the room topic // Change the room topic
topic: function(room_id, args) { topic: new Command("topic", "<topic>", function(room_id, args) {
if (args) { if (args) {
return success( return success(
MatrixClientPeg.get().setRoomTopic(room_id, args) MatrixClientPeg.get().setRoomTopic(room_id, args)
); );
} }
return reject("Usage: /topic <topic>"); return reject(this.getUsage());
}, }),
// Invite a user // Invite a user
invite: function(room_id, args) { invite: new Command("invite", "<userId>", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+)$/); var matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
@ -88,11 +128,11 @@ var commands = {
); );
} }
} }
return reject("Usage: /invite <userId>"); return reject(this.getUsage());
}, }),
// Join a room // Join a room
join: function(room_id, args) { join: new Command("join", "<room_alias>", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+)$/); var matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
@ -101,8 +141,7 @@ var commands = {
return reject("Usage: /join #alias:domain"); return reject("Usage: /join #alias:domain");
} }
if (!room_alias.match(/:/)) { if (!room_alias.match(/:/)) {
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); room_alias += ':' + MatrixClientPeg.get().getDomain();
room_alias += ':' + domain;
} }
// Try to find a room with this alias // Try to find a room with this alias
@ -135,21 +174,20 @@ var commands = {
); );
} }
} }
return reject("Usage: /join <room_alias>"); return reject(this.getUsage());
}, }),
part: function(room_id, args) { part: new Command("part", "[#alias:domain]", function(room_id, args) {
var targetRoomId; var targetRoomId;
if (args) { if (args) {
var matches = args.match(/^(\S+)$/); var matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
var room_alias = matches[1]; var room_alias = matches[1];
if (room_alias[0] !== '#') { if (room_alias[0] !== '#') {
return reject("Usage: /part [#alias:domain]"); return reject(this.getUsage());
} }
if (!room_alias.match(/:/)) { if (!room_alias.match(/:/)) {
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); room_alias += ':' + MatrixClientPeg.get().getDomain();
room_alias += ':' + domain;
} }
// Try to find a room with this alias // Try to find a room with this alias
@ -182,10 +220,10 @@ var commands = {
dis.dispatch({action: 'view_next_room'}); dis.dispatch({action: 'view_next_room'});
}) })
); );
}, }),
// Kick a user from the room with an optional reason // Kick a user from the room with an optional reason
kick: function(room_id, args) { kick: new Command("kick", "<userId> [<reason>]", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/); var matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) { if (matches) {
@ -194,11 +232,11 @@ var commands = {
); );
} }
} }
return reject("Usage: /kick <userId> [<reason>]"); return reject(this.getUsage());
}, }),
// Ban a user from the room with an optional reason // Ban a user from the room with an optional reason
ban: function(room_id, args) { ban: new Command("ban", "<userId> [<reason>]", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/); var matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) { if (matches) {
@ -207,11 +245,11 @@ var commands = {
); );
} }
} }
return reject("Usage: /ban <userId> [<reason>]"); return reject(this.getUsage());
}, }),
// Unban a user from the room // Unban a user from the room
unban: function(room_id, args) { unban: new Command("unban", "<userId>", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+)$/); var matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
@ -221,11 +259,11 @@ var commands = {
); );
} }
} }
return reject("Usage: /unban <userId>"); return reject(this.getUsage());
}, }),
// Define the power level of a user // Define the power level of a user
op: function(room_id, args) { op: new Command("op", "<userId> [<power level>]", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+?)( +(\d+))?$/); var matches = args.match(/^(\S+?)( +(\d+))?$/);
var powerLevel = 50; // default power level for op var powerLevel = 50; // default power level for op
@ -250,11 +288,11 @@ var commands = {
} }
} }
} }
return reject("Usage: /op <userId> [<power level>]"); return reject(this.getUsage());
}, }),
// Reset the power level of a user // Reset the power level of a user
deop: function(room_id, args) { deop: new Command("deop", "<userId>", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+)$/); var matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
@ -273,12 +311,14 @@ var commands = {
); );
} }
} }
return reject("Usage: /deop <userId>"); return reject(this.getUsage());
} })
}; };
// helpful aliases // helpful aliases
commands.j = commands.join; var aliases = {
j: "join"
}
module.exports = { module.exports = {
/** /**
@ -298,13 +338,26 @@ module.exports = {
var cmd = bits[1].substring(1).toLowerCase(); var cmd = bits[1].substring(1).toLowerCase();
var args = bits[3]; var args = bits[3];
if (cmd === "me") return null; if (cmd === "me") return null;
if (aliases[cmd]) {
cmd = aliases[cmd];
}
if (commands[cmd]) { if (commands[cmd]) {
return commands[cmd](roomId, args); return commands[cmd].run(roomId, args);
} }
else { else {
return reject("Unrecognised command: " + input); return reject("Unrecognised command: " + input);
} }
} }
return null; // not a command 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", "<action>", function(){}));
return cmds;
} }
}; };

View file

@ -32,8 +32,6 @@ const MATCH_REGEX = /(^|\s)(\S+)$/;
class TabComplete { class TabComplete {
constructor(opts) { constructor(opts) {
opts.startingWordSuffix = opts.startingWordSuffix || "";
opts.wordSuffix = opts.wordSuffix || "";
opts.allowLooping = opts.allowLooping || false; opts.allowLooping = opts.allowLooping || false;
opts.autoEnterTabComplete = opts.autoEnterTabComplete || false; opts.autoEnterTabComplete = opts.autoEnterTabComplete || false;
opts.onClickCompletes = opts.onClickCompletes || false; opts.onClickCompletes = opts.onClickCompletes || false;
@ -58,7 +56,7 @@ class TabComplete {
// assign onClick listeners for each entry to complete the text // assign onClick listeners for each entry to complete the text
this.list.forEach((l) => { this.list.forEach((l) => {
l.onClick = () => { 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. * 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) { completeTo(entry) {
this.textArea.value = this._replaceWith(someVal, true); this.textArea.value = this._replaceWith(
entry.getFillText(), true, entry.getSuffix(this.isFirstWord)
);
this.stopTabCompleting(); this.stopTabCompleting();
// keep focus on the text area // keep focus on the text area
this.textArea.focus(); this.textArea.focus();
@ -222,8 +222,9 @@ class TabComplete {
if (!this.inPassiveMode) { if (!this.inPassiveMode) {
// set textarea to this new value // set textarea to this new value
this.textArea.value = this._replaceWith( this.textArea.value = this._replaceWith(
this.matchedList[this.currentIndex].text, this.matchedList[this.currentIndex].getFillText(),
this.currentIndex !== 0 // don't suffix the original text! 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 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 // 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 // replace the partial word AND the character of whitespace. We want to
@ -258,13 +259,12 @@ class TabComplete {
boundaryChar = ""; boundaryChar = "";
} }
var replacementText = ( suffix = suffix || "";
boundaryChar + newVal + ( if (!includeSuffix) {
includeSuffix ? suffix = "";
(this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix) : }
""
) var replacementText = boundaryChar + newVal + suffix;
);
return this.originalText.replace(MATCH_REGEX, function() { return this.originalText.replace(MATCH_REGEX, function() {
return replacementText; // function form to avoid `$` special-casing return replacementText; // function form to avoid `$` special-casing
}); });

View file

@ -28,6 +28,14 @@ class Entry {
return this.text; 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 * @return {ReactClass} Raw JSX
*/ */
@ -42,6 +50,14 @@ class Entry {
return null; 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. * 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 { class MemberEntry extends Entry {
constructor(member) { constructor(member) {
super(member.name || member.userId); super(member.name || member.userId);
@ -66,6 +107,10 @@ class MemberEntry extends Entry {
getKey() { getKey() {
return this.member.userId; return this.member.userId;
} }
getSuffix(isFirstWord) {
return isFirstWord ? ": " : " ";
}
} }
MemberEntry.fromMemberList = function(members) { MemberEntry.fromMemberList = function(members) {
@ -99,3 +144,4 @@ MemberEntry.fromMemberList = function(members) {
module.exports.Entry = Entry; module.exports.Entry = Entry;
module.exports.MemberEntry = MemberEntry; module.exports.MemberEntry = MemberEntry;
module.exports.CommandEntry = CommandEntry;

View file

@ -66,7 +66,7 @@ function textForMemberEvent(ev) {
function textForTopicEvent(ev) { function textForTopicEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); 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) { function textForRoomNameEvent(ev) {

View file

@ -127,6 +127,11 @@ module.exports = {
cached = true; cached = true;
} }
if (!primaryColor) {
primaryColor = "#76CFA6"; // Vector green
secondaryColor = "#EAF5F0"; // Vector light green
}
if (!secondaryColor) { if (!secondaryColor) {
var x = 0.16; // average weighting factor calculated from vector green & light green var x = 0.16; // average weighting factor calculated from vector green & light green
var rgb = hexToRgb(primaryColor); var rgb = hexToRgb(primaryColor);
@ -146,6 +151,13 @@ module.exports = {
tertiaryColor = rgbToHex(rgb1); tertiaryColor = rgbToHex(rgb1);
} }
if (colors[0] === primaryColor &&
colors[1] === secondaryColor &&
colors[2] === tertiaryColor)
{
return;
}
colors = [primaryColor, secondaryColor, tertiaryColor]; colors = [primaryColor, secondaryColor, tertiaryColor];
// go through manually fixing up the stylesheets. // go through manually fixing up the stylesheets.

View file

@ -16,7 +16,8 @@ limitations under the License.
var dis = require("./dispatcher"); 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) * This class watches for user activity (moving the mouse or pressing a key)
@ -31,8 +32,14 @@ class UserActivity {
start() { start() {
document.onmousemove = this._onUserActivity.bind(this); document.onmousemove = this._onUserActivity.bind(this);
document.onkeypress = 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.lastActivityAtTs = new Date().getTime();
this.lastDispatchAtTs = 0; this.lastDispatchAtTs = 0;
this.activityEndTimer = undefined;
} }
/** /**
@ -41,10 +48,19 @@ class UserActivity {
stop() { stop() {
document.onmousemove = undefined; document.onmousemove = undefined;
document.onkeypress = 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) { _onUserActivity(event) {
if (event.screenX) { if (event.screenX && event.type == "mousemove") {
if (event.screenX === this.lastScreenX && if (event.screenX === this.lastScreenX &&
event.screenY === this.lastScreenY) event.screenY === this.lastScreenY)
{ {
@ -55,12 +71,32 @@ class UserActivity {
this.lastScreenY = event.screenY; this.lastScreenY = event.screenY;
} }
this.lastActivityAtTs = (new Date).getTime(); this.lastActivityAtTs = new Date().getTime();
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL) { if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs; this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({ dis.dispatch({
action: 'user_activity' 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
);
} }
} }
} }

View file

@ -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.ScrollPanel'] = require('./components/structures/ScrollPanel');
module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); 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.MemberAvatar'] = require('./components/views/avatars/MemberAvatar');
module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar'); module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar');
module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton'); module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton');
@ -41,6 +46,7 @@ module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/
module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog');
module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); 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.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.ProgressBar'] = require('./components/views/elements/ProgressBar');
module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg'); 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.elements.UserSelector'] = require('./components/views/elements/UserSelector');
@ -64,8 +70,10 @@ module.exports.components['views.rooms.MemberInfo'] = require('./components/view
module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList'); module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList');
module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile'); 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.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.RoomHeader'] = require('./components/views/rooms/RoomHeader');
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); 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.RoomSettings'] = require('./components/views/rooms/RoomSettings');
module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile');
module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile'); module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile');

View file

@ -251,7 +251,7 @@ module.exports = React.createClass({
var UserSelector = sdk.getComponent("elements.UserSelector"); var UserSelector = sdk.getComponent("elements.UserSelector");
var RoomHeader = sdk.getComponent("rooms.RoomHeader"); var RoomHeader = sdk.getComponent("rooms.RoomHeader");
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); var domain = MatrixClientPeg.get().getDomain();
return ( return (
<div className="mx_CreateRoom"> <div className="mx_CreateRoom">

View file

@ -30,6 +30,7 @@ var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration"); var PostRegistration = require("./login/PostRegistration");
var Modal = require("../../Modal"); var Modal = require("../../Modal");
var Tinter = require("../../Tinter");
var sdk = require('../../index'); var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools'); var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix"); var linkifyMatrix = require("../../linkify-matrix");
@ -63,7 +64,7 @@ module.exports = React.createClass({
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
ready: false, ready: false,
width: 10000 width: 10000,
}; };
if (s.logged_in) { if (s.logged_in) {
if (MatrixClientPeg.get().getRooms().length) { if (MatrixClientPeg.get().getRooms().length) {
@ -233,6 +234,13 @@ module.exports = React.createClass({
}); });
this.notifyNewScreen('register'); this.notifyNewScreen('register');
break; break;
case 'start_password_recovery':
if (this.state.logged_in) return;
this.replaceState({
screen: 'forgot_password'
});
this.notifyNewScreen('forgot_password');
break;
case 'token_login': case 'token_login':
if (this.state.logged_in) return; if (this.state.logged_in) return;
@ -296,7 +304,7 @@ module.exports = React.createClass({
}); });
break; break;
case 'view_room': case 'view_room':
this._viewRoom(payload.room_id); this._viewRoom(payload.room_id, payload.show_settings);
break; break;
case 'view_prev_room': case 'view_prev_room':
roomIndexDelta = -1; roomIndexDelta = -1;
@ -349,8 +357,29 @@ module.exports = React.createClass({
this.notifyNewScreen('settings'); this.notifyNewScreen('settings');
break; break;
case 'view_create_room': case 'view_create_room':
this._setPage(this.PageTypes.CreateRoom); //this._setPage(this.PageTypes.CreateRoom);
this.notifyNewScreen('new'); //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; break;
case 'view_room_directory': case 'view_room_directory':
this._setPage(this.PageTypes.RoomDirectory); this._setPage(this.PageTypes.RoomDirectory);
@ -391,7 +420,7 @@ module.exports = React.createClass({
}); });
}, },
_viewRoom: function(roomId) { _viewRoom: function(roomId, showSettings) {
// before we switch room, record the scroll state of the current room // before we switch room, record the scroll state of the current room
this._updateScrollMap(); this._updateScrollMap();
@ -411,7 +440,16 @@ module.exports = React.createClass({
if (room) { if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room); var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
if (theAlias) presentedId = theAlias; 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); this.notifyNewScreen('room/'+presentedId);
newState.ready = true; newState.ready = true;
} }
@ -420,6 +458,9 @@ module.exports = React.createClass({
var scrollState = this.scrollStateMap[roomId]; var scrollState = this.scrollStateMap[roomId];
this.refs.roomView.restoreScrollState(scrollState); 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 // update scrollStateMap according to the current scroll state of the
@ -505,7 +546,9 @@ module.exports = React.createClass({
UserActivity.start(); UserActivity.start();
Presence.start(); Presence.start();
cli.startClient({ cli.startClient({
pendingEventOrdering: "end" pendingEventOrdering: "end",
// deliberately huge limit for now to avoid hitting gappy /sync's until gappy /sync performance improves
initialSyncLimit: 250,
}); });
}, },
@ -559,6 +602,11 @@ module.exports = React.createClass({
action: 'token_login', action: 'token_login',
params: params params: params
}); });
} else if (screen == 'forgot_password') {
dis.dispatch({
action: 'start_password_recovery',
params: params
});
} else if (screen == 'new') { } else if (screen == 'new') {
dis.dispatch({ dis.dispatch({
action: 'view_create_room', action: 'view_create_room',
@ -614,6 +662,8 @@ module.exports = React.createClass({
onUserClick: function(event, userId) { onUserClick: function(event, userId) {
event.preventDefault(); event.preventDefault();
/*
var MemberInfo = sdk.getComponent('rooms.MemberInfo'); var MemberInfo = sdk.getComponent('rooms.MemberInfo');
var member = new Matrix.RoomMember(null, userId); var member = new Matrix.RoomMember(null, userId);
ContextualMenu.createMenu(MemberInfo, { ContextualMenu.createMenu(MemberInfo, {
@ -621,6 +671,14 @@ module.exports = React.createClass({
right: window.innerWidth - event.pageX, right: window.innerWidth - event.pageX,
top: event.pageY top: event.pageY
}); });
*/
var member = new Matrix.RoomMember(null, userId);
if (!member) { return; }
dis.dispatch({
action: 'view_user',
member: member,
});
}, },
onLogoutClick: function(event) { onLogoutClick: function(event) {
@ -668,6 +726,10 @@ module.exports = React.createClass({
this.showScreen("login"); this.showScreen("login");
}, },
onForgotPasswordClick: function() {
this.showScreen("forgot_password");
},
onRegistered: function(credentials) { onRegistered: function(credentials) {
this.onLoggedIn(credentials); this.onLoggedIn(credentials);
// do post-registration stuff // do post-registration stuff
@ -706,6 +768,7 @@ module.exports = React.createClass({
var CreateRoom = sdk.getComponent('structures.CreateRoom'); var CreateRoom = sdk.getComponent('structures.CreateRoom');
var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); var RoomDirectory = sdk.getComponent('structures.RoomDirectory');
var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); 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 // needs to be before normal PageTypes as you are logged in technically
if (this.state.screen == 'post_registration') { if (this.state.screen == 'post_registration') {
@ -801,13 +864,21 @@ module.exports = React.createClass({
onLoggedIn={this.onRegistered} onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick} /> onLoginClick={this.onLoginClick} />
); );
} else if (this.state.screen == 'forgot_password') {
return (
<ForgotPassword
homeserverUrl={this.props.config.default_hs_url}
identityServerUrl={this.props.config.default_is_url}
onComplete={this.onLoginClick} />
);
} else { } else {
return ( return (
<Login <Login
onLoggedIn={this.onLoggedIn} onLoggedIn={this.onLoggedIn}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
homeserverUrl={this.props.config.default_hs_url} homeserverUrl={this.props.config.default_hs_url}
identityServerUrl={this.props.config.default_is_url} /> identityServerUrl={this.props.config.default_is_url}
onForgotPasswordClick={this.onForgotPasswordClick} />
); );
} }
} }

View file

@ -35,11 +35,15 @@ var sdk = require('../../index');
var CallHandler = require('../../CallHandler'); var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete"); var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry; var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
var CommandEntry = require("../../TabCompleteEntries").CommandEntry;
var Resend = require("../../Resend"); var Resend = require("../../Resend");
var SlashCommands = require("../../SlashCommands");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var Tinter = require("../../Tinter");
var PAGINATE_SIZE = 20; var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20; var INITIAL_SIZE = 20;
var SEND_READ_RECEIPT_DELAY = 2000;
var DEBUG_SCROLL = false; var DEBUG_SCROLL = false;
@ -74,6 +78,9 @@ module.exports = React.createClass({
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
hasUnsentMessages: this._hasUnsentMessages(room), hasUnsentMessages: this._hasUnsentMessages(room),
callState: null, callState: null,
guestsCanJoin: false,
readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null,
readMarkerGhostEventId: undefined
} }
}, },
@ -81,6 +88,7 @@ module.exports = React.createClass({
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
@ -88,8 +96,6 @@ module.exports = React.createClass({
// xchat-style tab complete, add a colon if tab // xchat-style tab complete, add a colon if tab
// completing at the start of the text // completing at the start of the text
this.tabComplete = new TabComplete({ this.tabComplete = new TabComplete({
startingWordSuffix: ": ",
wordSuffix: " ",
allowLooping: false, allowLooping: false,
autoEnterTabComplete: true, autoEnterTabComplete: true,
onClickCompletes: true, onClickCompletes: true,
@ -106,15 +112,27 @@ module.exports = React.createClass({
// succeeds then great, show the preview (but we still may be able to /join!). // succeeds then great, show the preview (but we still may be able to /join!).
if (!this.state.room) { if (!this.state.room) {
console.log("Attempting to peek into room %s", this.props.roomId); console.log("Attempting to peek into room %s", this.props.roomId);
MatrixClientPeg.get().peekInRoom(this.props.roomId).done(function() { MatrixClientPeg.get().peekInRoom(this.props.roomId).done(() => {
// we don't need to do anything - JS SDK will emit Room events // we don't need to do anything - JS SDK will emit Room events
// which will update the UI. // which will update the UI. We *do* however need to know if we
// can join the room so we can fiddle with the UI appropriately.
var peekedRoom = MatrixClientPeg.get().getRoom(this.props.roomId);
if (!peekedRoom) {
return;
}
var guestAccessEvent = peekedRoom.currentState.getStateEvents("m.room.guest_access", "");
if (!guestAccessEvent) {
return;
}
if (guestAccessEvent.getContent().guest_access === "can_join") {
this.setState({
guestsCanJoin: true
});
}
}, function(err) { }, function(err) {
console.error("Failed to peek into room: %s", err); console.error("Failed to peek into room: %s", err);
}); });
} }
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -124,21 +142,22 @@ module.exports = React.createClass({
// (We could use isMounted, but facebook have deprecated that.) // (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true; this.unmounted = true;
if (this.refs.messagePanel) { if (this.refs.roomView) {
// disconnect the D&D event listeners from the message panel. This // disconnect the D&D event listeners from the room view. This
// is really just for hygiene - the messagePanel is going to be // is really just for hygiene - we're going to be
// deleted anyway, so it doesn't matter if the event listeners // deleted anyway, so it doesn't matter if the event listeners
// don't get cleaned up. // don't get cleaned up.
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); var roomView = ReactDOM.findDOMNode(this.refs.roomView);
messagePanel.removeEventListener('drop', this.onDrop); roomView.removeEventListener('drop', this.onDrop);
messagePanel.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragover', this.onDragOver);
messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd);
messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
} }
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
@ -146,6 +165,8 @@ module.exports = React.createClass({
} }
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
Tinter.tint(); // reset colourscheme
}, },
onAction: function(payload) { onAction: function(payload) {
@ -199,6 +220,12 @@ module.exports = React.createClass({
break; break;
case 'user_activity': 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(); this.sendReadReceipt();
break; break;
} }
@ -259,9 +286,58 @@ module.exports = React.createClass({
} }
}, },
updateTint: function() {
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (!room) return;
var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
var color_scheme = {};
if (color_scheme_event) {
color_scheme = color_scheme_event.getContent();
// XXX: we should validate the event
}
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
},
onRoomAccountData: function(room, event) {
if (room.roomId == this.props.roomId) {
if (event.getType === "org.matrix.room.color_scheme") {
var color_scheme = event.getContent();
// XXX: we should validate the event
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}
}
},
onRoomReceipt: function(receiptEvent, room) { onRoomReceipt: function(receiptEvent, room) {
if (room.roomId == this.props.roomId) { if (room.roomId == this.props.roomId) {
this.forceUpdate(); var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
var readMarkerGhostEventId = this.state.readMarkerGhostEventId;
if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) {
readMarkerGhostEventId = this.state.readMarkerEventId;
}
// if the event after the one referenced in the read receipt if sent by us, do nothing since
// this is a temporary period before the synthesized receipt for our own message arrives
var readMarkerGhostEventIndex;
for (var i = 0; i < room.timeline.length; ++i) {
if (room.timeline[i].getId() == readMarkerGhostEventId) {
readMarkerGhostEventIndex = i;
break;
}
}
if (readMarkerGhostEventIndex + 1 < room.timeline.length) {
var nextEvent = room.timeline[readMarkerGhostEventIndex + 1];
if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) {
readMarkerGhostEventId = undefined;
}
}
this.setState({
readMarkerEventId: readMarkerEventId,
readMarkerGhostEventId: readMarkerGhostEventId,
});
} }
}, },
@ -338,6 +414,14 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
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); this._updateTabCompleteList(this.state.room);
}, },
@ -346,7 +430,9 @@ module.exports = React.createClass({
return; return;
} }
this.tabComplete.setCompletionList( this.tabComplete.setCompletionList(
MemberEntry.fromMemberList(room.getJoinedMembers()) MemberEntry.fromMemberList(room.getJoinedMembers()).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
)
); );
}, },
@ -354,13 +440,10 @@ module.exports = React.createClass({
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
this.refs.messagePanel.initialised = true; 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.scrollToBottom();
this.sendReadReceipt(); this.sendReadReceipt();
this.updateTint();
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
@ -682,10 +765,10 @@ module.exports = React.createClass({
var EventTile = sdk.getComponent('rooms.EventTile'); var EventTile = sdk.getComponent('rooms.EventTile');
var prevEvent = null; // the last event we showed var prevEvent = null; // the last event we showed
var readReceiptEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap); var 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++) { for (var i = startIdx; i < this.state.room.timeline.length; i++) {
var mxEv = this.state.room.timeline[i]; var mxEv = this.state.room.timeline[i];
@ -699,6 +782,25 @@ module.exports = React.createClass({
} }
} }
// now we've decided whether or not to show this message,
// add the read up to marker if appropriate
// doing this here means we implicitly do not show the marker
// if it's at the bottom
// NB. it would be better to decide where the read marker was going
// when the state changed rather than here in the render method, but
// this is where we decide what messages we show so it's the only
// place we know whether we're at the bottom or not.
var self = this;
var mxEvSender = mxEv.sender ? mxEv.sender.userId : null;
if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) {
var hr;
hr = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) {
self.readMarkerNode = n;
}} />);
readMarkerIndex = ret.length;
ret.push(<li key="_readupto" className="mx_RoomView_myReadMarker_container">{hr}</li>);
}
// is this a continuation of the previous message? // is this a continuation of the previous message?
var continuation = false; var continuation = false;
if (prevEvent !== null) { if (prevEvent !== null) {
@ -735,13 +837,29 @@ module.exports = React.createClass({
</li> </li>
); );
if (eventId == readReceiptEventId) { // A read up to marker has died and returned as a ghost!
ret.push(<hr className="mx_RoomView_myReadMarker" />); // 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; prevEvent = mxEv;
} }
// splice the read marker ghost in now that we know whether the read receipt
// is the last element or not, because we only decide as we're going along.
if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) {
var hr;
hr = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) {
Velocity(n, {opacity: '0', width: '10%'}, {duration: 400, easing: 'easeInSine', delay: 1000, complete: function() {
self.setState({readMarkerGhostEventId: undefined});
}});
}} />);
ret.splice(ghostIndex, 0, (
<li key="_readuptoghost" className="mx_RoomView_myReadMarker_container">{hr}</li>
));
}
return ret; return ret;
}, },
@ -769,9 +887,27 @@ module.exports = React.createClass({
old_history_visibility = "shared"; 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 = []; var deferreds = [];
if (old_name != newVals.name && newVals.name != undefined && newVals.name) { if (old_name != newVals.name && newVals.name != undefined) {
deferreds.push( deferreds.push(
MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name) MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name)
); );
@ -819,26 +955,128 @@ module.exports = React.createClass({
); );
} }
deferreds.push( if (newVals.alias_operations) {
MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, { var oplist = [];
allowRead: newVals.guest_read, for (var i = 0; i < newVals.alias_operations.length; i++) {
allowJoin: newVals.guest_join var alias_operation = newVals.alias_operations[i];
}) switch (alias_operation.type) {
); case 'put':
oplist.push(
MatrixClientPeg.get().createAlias(
alias_operation.alias, this.state.room.roomId
)
);
break;
case 'delete':
oplist.push(
MatrixClientPeg.get().deleteAlias(
alias_operation.alias
)
);
break;
default:
console.log("Unknown alias operation, ignoring: " + alias_operation.type);
}
}
if (oplist.length) {
var deferred = oplist[0];
oplist.splice(1).forEach(function (f) {
deferred = deferred.then(f);
});
deferreds.push(deferred);
}
}
if (newVals.tag_operations) {
// FIXME: should probably be factored out with alias_operations above
var oplist = [];
for (var i = 0; i < newVals.tag_operations.length; i++) {
var tag_operation = newVals.tag_operations[i];
switch (tag_operation.type) {
case 'put':
oplist.push(
MatrixClientPeg.get().setRoomTag(
this.props.roomId, tag_operation.tag, {}
)
);
break;
case 'delete':
oplist.push(
MatrixClientPeg.get().deleteRoomTag(
this.props.roomId, tag_operation.tag
)
);
break;
default:
console.log("Unknown tag operation, ignoring: " + tag_operation.type);
}
}
if (oplist.length) {
var deferred = oplist[0];
oplist.splice(1).forEach(function (f) {
deferred = deferred.then(f);
});
deferreds.push(deferred);
}
}
if (old_canonical_alias !== newVals.canonical_alias) {
deferreds.push(
MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.canonical_alias", {
alias: newVals.canonical_alias
}, ""
)
);
}
if (newVals.color_scheme) {
deferreds.push(
MatrixClientPeg.get().setRoomAccountData(
this.state.room.roomId, "org.matrix.room.color_scheme", newVals.color_scheme
)
);
}
if (old_guest_read != newVals.guest_read ||
old_guest_join != newVals.guest_join)
{
deferreds.push(
MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, {
allowRead: newVals.guest_read,
allowJoin: newVals.guest_join
})
);
}
if (deferreds.length) { if (deferreds.length) {
var self = this; var self = this;
q.all(deferreds).fail(function(err) { q.allSettled(deferreds).then(
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); function(results) {
Modal.createDialog(ErrorDialog, { var fails = results.filter(function(result) { return result.state !== "fulfilled" });
title: "Failed to set state", if (fails.length) {
description: err.toString() 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 { } else {
this.setState({ this.setState({
editingRoomSettings: false, editingRoomSettings: false,
@ -906,23 +1144,27 @@ module.exports = React.createClass({
onSaveClick: function() { onSaveClick: function() {
this.setState({ this.setState({
editingRoomSettings: false,
uploadingRoomSettings: true, uploadingRoomSettings: true,
}); });
this.uploadNewState({ this.uploadNewState({
name: this.refs.header.getRoomName(), name: this.refs.header.getRoomName(),
topic: this.refs.room_settings.getTopic(), topic: this.refs.header.getTopic(),
join_rule: this.refs.room_settings.getJoinRules(), join_rule: this.refs.room_settings.getJoinRules(),
history_visibility: this.refs.room_settings.getHistoryVisibility(), history_visibility: this.refs.room_settings.getHistoryVisibility(),
are_notifications_muted: this.refs.room_settings.areNotificationsMuted(), are_notifications_muted: this.refs.room_settings.areNotificationsMuted(),
power_levels: this.refs.room_settings.getPowerLevels(), 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_join: this.refs.room_settings.canGuestsJoin(),
guest_read: this.refs.room_settings.canGuestsRead() guest_read: this.refs.room_settings.canGuestsRead(),
color_scheme: this.refs.room_settings.getColorScheme(),
}); });
}, },
onCancelClick: function() { onCancelClick: function() {
this.updateTint();
this.setState({editingRoomSettings: false}); this.setState({editingRoomSettings: false});
}, },
@ -1070,26 +1312,32 @@ module.exports = React.createClass({
// a minimum of the height of the video element, whilst also capping it from pushing out the page // 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 // 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. // a maxHeight on the underlying remote video tag.
var auxPanelMaxHeight;
// header + footer + status + give us at least 120px of scrollback at all times.
var auxPanelMaxHeight = window.innerHeight -
(83 + // height of RoomHeader
36 + // height of the status area
72 + // minimum height of the message compmoser
120); // amount of desired scrollback
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
if (this.refs.callView) { if (this.refs.callView) {
var video = this.refs.callView.getVideoView().getRemoteVideoElement(); var video = this.refs.callView.getVideoView().getRemoteVideoElement();
// header + footer + status + give us at least 100px of scrollback at all times.
auxPanelMaxHeight = window.innerHeight -
(83 + 72 +
sdk.getComponent('rooms.MessageComposer').MAX_HEIGHT +
100);
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
video.style.maxHeight = auxPanelMaxHeight + "px"; video.style.maxHeight = auxPanelMaxHeight + "px";
// the above might have made the video panel resize itself, so now // the above might have made the video panel resize itself, so now
// we need to tell the gemini panel to adapt. // we need to tell the gemini panel to adapt.
this.onChildResize(); this.onChildResize();
} }
// we need to do this for general auxPanels too
if (this.refs.auxPanel) {
this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px";
}
}, },
onFullscreenClick: function() { onFullscreenClick: function() {
@ -1132,6 +1380,13 @@ module.exports = React.createClass({
} }
}, },
showSettings: function(show) {
// XXX: this is a bit naughty; we should be doing this via props
if (show) {
this.setState({editingRoomSettings: true});
}
},
render: function() { render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer'); var MessageComposer = sdk.getComponent('rooms.MessageComposer');
@ -1140,6 +1395,7 @@ module.exports = React.createClass({
var SearchBar = sdk.getComponent("rooms.SearchBar"); var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); var TintableSvg = sdk.getComponent("elements.TintableSvg");
var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
if (!this.state.room) { if (!this.state.room) {
if (this.props.roomId) { if (this.props.roomId) {
@ -1281,7 +1537,7 @@ module.exports = React.createClass({
var aux = null; var aux = null;
if (this.state.editingRoomSettings) { if (this.state.editingRoomSettings) {
aux = <RoomSettings ref="room_settings" onSaveClick={this.onSaveClick} room={this.state.room} />; aux = <RoomSettings ref="room_settings" onSaveClick={this.onSaveClick} onCancelClick={this.onCancelClick} room={this.state.room} />;
} }
else if (this.state.uploadingRoomSettings) { else if (this.state.uploadingRoomSettings) {
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
@ -1290,6 +1546,12 @@ module.exports = React.createClass({
else if (this.state.searching) { else if (this.state.searching) {
aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress } onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch}/>; aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress } onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch}/>;
} }
else if (this.state.guestsCanJoin && MatrixClientPeg.get().isGuest() &&
(!myMember || myMember.membership !== "join")) {
aux = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked} canJoin={true} />
);
}
var conferenceCallNotification = null; var conferenceCallNotification = null;
if (this.state.displayConfCallNotification) { if (this.state.displayConfCallNotification) {
@ -1309,7 +1571,7 @@ module.exports = React.createClass({
fileDropTarget = <div className="mx_RoomView_fileDropTarget"> fileDropTarget = <div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel" title="Drop File Here"> <div className="mx_RoomView_fileDropTargetLabel" title="Drop File Here">
<TintableSvg src="img/upload-big.svg" width="45" height="59"/><br/> <TintableSvg src="img/upload-big.svg" width="45" height="59"/><br/>
Drop File Here Drop file here to upload
</div> </div>
</div>; </div>;
} }
@ -1410,7 +1672,7 @@ module.exports = React.createClass({
); );
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo} <RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
editing={this.state.editingRoomSettings} editing={this.state.editingRoomSettings}
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
@ -1423,8 +1685,8 @@ module.exports = React.createClass({
onLeaveClick={ onLeaveClick={
(myMember && myMember.membership === "join") ? this.onLeaveClick : null (myMember && myMember.membership === "join") ? this.onLeaveClick : null
} /> } />
{ fileDropTarget } <div className="mx_RoomView_auxPanel" ref="auxPanel">
<div className="mx_RoomView_auxPanel"> { fileDropTarget }
<CallView ref="callView" room={this.state.room} ConferenceHandler={this.props.ConferenceHandler} <CallView ref="callView" room={this.state.room} ConferenceHandler={this.props.ConferenceHandler}
onResize={this.onChildResize} /> onResize={this.onChildResize} />
{ conferenceCallNotification } { conferenceCallNotification }

View file

@ -57,7 +57,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
} }
} }
if (!upload) { if (!upload) {
upload = uploads[0]; return <div />
} }
var innerProgressStyle = { var innerProgressStyle = {

View file

@ -21,6 +21,7 @@ var dis = require("../../dispatcher");
var q = require('q'); var q = require('q');
var version = require('../../../package.json').version; var version = require('../../../package.json').version;
var UserSettingsStore = require('../../UserSettingsStore'); var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'UserSettings', displayName: 'UserSettings',
@ -83,6 +84,12 @@ module.exports = React.createClass({
} }
}, },
onAvatarPickerClick: function(ev) {
if (this.refs.file_label) {
this.refs.file_label.click();
}
},
onAvatarSelected: function(ev) { onAvatarSelected: function(ev) {
var self = this; var self = this;
var changeAvatar = this.refs.changeAvatar; var changeAvatar = this.refs.changeAvatar;
@ -172,7 +179,7 @@ module.exports = React.createClass({
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
accountJsx = ( accountJsx = (
<div className="mx_UserSettings_button" onClick={this.onUpgradeClicked}> <div className="mx_UserSettings_button" onClick={this.onUpgradeClicked}>
Upgrade (It's free!) Create an account
</div> </div>
); );
} }
@ -193,6 +200,8 @@ module.exports = React.createClass({
<div className="mx_UserSettings"> <div className="mx_UserSettings">
<RoomHeader simpleHeader="Settings" /> <RoomHeader simpleHeader="Settings" />
<GeminiScrollbar className="mx_UserSettings_body" autoshow={true}>
<h2>Profile</h2> <h2>Profile</h2>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
@ -222,13 +231,15 @@ module.exports = React.createClass({
</div> </div>
<div className="mx_UserSettings_avatarPicker"> <div className="mx_UserSettings_avatarPicker">
<ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl} <div onClick={ this.onAvatarPickerClick }>
showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/> <ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/>
</div>
<div className="mx_UserSettings_avatarPicker_edit"> <div className="mx_UserSettings_avatarPicker_edit">
<label htmlFor="avatarInput"> <label htmlFor="avatarInput" ref="file_label">
<img src="img/upload.svg" <img src="img/camera.svg"
alt="Upload avatar" title="Upload avatar" alt="Upload avatar" title="Upload avatar"
width="19" height="24" /> width="17" height="15" />
</label> </label>
<input id="avatarInput" type="file" onChange={this.onAvatarSelected}/> <input id="avatarInput" type="file" onChange={this.onAvatarSelected}/>
</div> </div>
@ -238,13 +249,12 @@ module.exports = React.createClass({
<h2>Account</h2> <h2>Account</h2>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
{accountJsx}
</div> <div className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
<div className="mx_UserSettings_logout">
<div className="mx_UserSettings_button" onClick={this.onLogoutClicked}>
Log out Log out
</div> </div>
{accountJsx}
</div> </div>
<h2>Notifications</h2> <h2>Notifications</h2>
@ -263,6 +273,8 @@ module.exports = React.createClass({
Version {this.state.clientVersion} Version {this.state.clientVersion}
</div> </div>
</div> </div>
</GeminiScrollbar>
</div> </div>
); );
} }

View file

@ -0,0 +1,199 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var sdk = require('../../../index');
var Modal = require("../../../Modal");
var MatrixClientPeg = require('../../../MatrixClientPeg');
var PasswordReset = require("../../../PasswordReset");
module.exports = React.createClass({
displayName: 'ForgotPassword',
propTypes: {
homeserverUrl: React.PropTypes.string,
identityServerUrl: React.PropTypes.string,
onComplete: React.PropTypes.func.isRequired
},
getInitialState: function() {
return {
enteredHomeserverUrl: this.props.homeserverUrl,
enteredIdentityServerUrl: this.props.identityServerUrl,
progress: null
};
},
submitPasswordReset: function(hsUrl, identityUrl, email, password) {
this.setState({
progress: "sending_email"
});
this.reset = new PasswordReset(hsUrl, identityUrl);
this.reset.resetPassword(email, password).done(() => {
this.setState({
progress: "sent_email"
});
}, (err) => {
this.showErrorDialog("Failed to send email: " + err.message);
this.setState({
progress: null
});
})
},
onVerify: function(ev) {
ev.preventDefault();
if (!this.reset) {
console.error("onVerify called before submitPasswordReset!");
return;
}
this.reset.checkEmailLinkClicked().done((res) => {
this.setState({ progress: "complete" });
}, (err) => {
this.showErrorDialog(err.message);
})
},
onSubmitForm: function(ev) {
ev.preventDefault();
if (!this.state.email) {
this.showErrorDialog("The email address linked to your account must be entered.");
}
else if (!this.state.password || !this.state.password2) {
this.showErrorDialog("A new password must be entered.");
}
else if (this.state.password !== this.state.password2) {
this.showErrorDialog("New passwords must match each other.");
}
else {
this.submitPasswordReset(
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
this.state.email, this.state.password
);
}
},
onInputChanged: function(stateKey, ev) {
this.setState({
[stateKey]: ev.target.value
});
},
onHsUrlChanged: function(newHsUrl) {
this.setState({
enteredHomeserverUrl: newHsUrl
});
},
onIsUrlChanged: function(newIsUrl) {
this.setState({
enteredIdentityServerUrl: newIsUrl
});
},
showErrorDialog: function(body, title) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: title,
description: body
});
},
render: function() {
var LoginHeader = sdk.getComponent("login.LoginHeader");
var LoginFooter = sdk.getComponent("login.LoginFooter");
var ServerConfig = sdk.getComponent("login.ServerConfig");
var Spinner = sdk.getComponent("elements.Spinner");
var resetPasswordJsx;
if (this.state.progress === "sending_email") {
resetPasswordJsx = <Spinner />
}
else if (this.state.progress === "sent_email") {
resetPasswordJsx = (
<div>
An email has been sent to {this.state.email}. Once you&#39;ve followed
the link it contains, click below.
<br />
<input className="mx_Login_submit" type="button" onClick={this.onVerify}
value="I have verified my email address" />
</div>
);
}
else if (this.state.progress === "complete") {
resetPasswordJsx = (
<div>
<p>Your password has been reset.</p>
<p>You have been logged out of all devices and will no longer receive push notifications.
To re-enable notifications, re-log in on each device.</p>
<input className="mx_Login_submit" type="button" onClick={this.props.onComplete}
value="Return to login screen" />
</div>
);
}
else {
resetPasswordJsx = (
<div>
To reset your password, enter the email address linked to your account:
<br />
<div>
<form onSubmit={this.onSubmitForm}>
<input className="mx_Login_field" ref="user" type="text"
value={this.state.email}
onChange={this.onInputChanged.bind(this, "email")}
placeholder="Email address" autoFocus />
<br />
<input className="mx_Login_field" ref="pass" type="password"
value={this.state.password}
onChange={this.onInputChanged.bind(this, "password")}
placeholder="New password" />
<br />
<input className="mx_Login_field" ref="pass" type="password"
value={this.state.password2}
onChange={this.onInputChanged.bind(this, "password2")}
placeholder="Confirm your new password" />
<br />
<input className="mx_Login_submit" type="submit" value="Send Reset Email" />
</form>
<ServerConfig ref="serverConfig"
withToggleButton={true}
defaultHsUrl={this.props.homeserverUrl}
defaultIsUrl={this.props.identityServerUrl}
onHsUrlChanged={this.onHsUrlChanged}
onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={0}/>
<LoginFooter />
</div>
</div>
);
}
return (
<div className="mx_Login">
<div className="mx_Login_box">
<LoginHeader />
{resetPasswordJsx}
</div>
</div>
);
}
});

View file

@ -33,7 +33,9 @@ module.exports = React.createClass({displayName: 'Login',
homeserverUrl: React.PropTypes.string, homeserverUrl: React.PropTypes.string,
identityServerUrl: React.PropTypes.string, identityServerUrl: React.PropTypes.string,
// login shouldn't know or care how registration is done. // 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() { getDefaultProps: function() {
@ -138,7 +140,9 @@ module.exports = React.createClass({displayName: 'Login',
switch (step) { switch (step) {
case 'm.login.password': case 'm.login.password':
return ( return (
<PasswordLogin onSubmit={this.onPasswordLogin} /> <PasswordLogin
onSubmit={this.onPasswordLogin}
onForgotPasswordClick={this.props.onForgotPasswordClick} />
); );
case 'm.login.cas': case 'm.login.cas':
return ( return (

View file

@ -159,6 +159,15 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_PASSWORD_LENGTH": case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`; errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`;
break; 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: default:
console.error("Unknown error code: %s", errCode); console.error("Unknown error code: %s", errCode);
errMsg = "An unknown error occurred."; errMsg = "An unknown error occurred.";

View file

@ -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 (
<span className="mx_BaseAvatar" {...this.props}>
<span className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (this.props.width * 0.65) + "px",
width: this.props.width + "px",
lineHeight: this.props.height + "px" }}>
{ initialLetter }
</span>
<img className="mx_BaseAvatar_image" src={imageUrl}
title={this.props.title} onError={this.onError}
width={this.props.width} height={this.props.height} />
</span>
);
}
return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={this.props.width} height={this.props.height}
title={this.props.title}
{...this.props} />
);
}
});

View file

@ -18,7 +18,7 @@ limitations under the License.
var React = require('react'); var React = require('react');
var Avatar = require('../../../Avatar'); var Avatar = require('../../../Avatar');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require("../../../index");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MemberAvatar', displayName: 'MemberAvatar',
@ -27,7 +27,7 @@ module.exports = React.createClass({
member: React.PropTypes.object.isRequired, member: React.PropTypes.object.isRequired,
width: React.PropTypes.number, width: React.PropTypes.number,
height: React.PropTypes.number, height: React.PropTypes.number,
resizeMethod: React.PropTypes.string, resizeMethod: React.PropTypes.string
}, },
getDefaultProps: function() { 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() { getInitialState: function() {
return { return this._getState(this.props);
imageUrl: this._computeUrl()
};
}, },
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() { render: function() {
// XXX: recalculates default avatar url constantly var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
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 (
<span className="mx_MemberAvatar" {...this.props}>
<span className="mx_MemberAvatar_initial" aria-hidden="true"
style={{ fontSize: (this.props.width * 0.65) + "px",
width: this.props.width + "px",
lineHeight: this.props.height + "px" }}>{ initial }</span>
<img className="mx_MemberAvatar_image" src={this.state.imageUrl} title={this.props.member.name}
onError={this.onError} width={this.props.width} height={this.props.height} />
</span>
);
}
return ( return (
<img className="mx_MemberAvatar mx_MemberAvatar_image" src={this.state.imageUrl} <BaseAvatar {...this.props} name={this.state.name} title={this.state.title}
onError={this.onError} idName={this.props.member.userId} url={this.state.imageUrl} />
width={this.props.width} height={this.props.height}
title={this.props.member.name}
{...this.props}
/>
); );
} }
}); });

View file

@ -16,10 +16,18 @@ limitations under the License.
var React = require('react'); var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var Avatar = require('../../../Avatar'); var Avatar = require('../../../Avatar');
var sdk = require("../../../index");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomAvatar', displayName: 'RoomAvatar',
propTypes: {
room: React.PropTypes.object.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number,
resizeMethod: React.PropTypes.string
},
getDefaultProps: function() { getDefaultProps: function() {
return { return {
width: 36, width: 36,
@ -29,84 +37,54 @@ module.exports = React.createClass({
}, },
getInitialState: function() { getInitialState: function() {
this._update();
return { return {
imageUrl: this._nextUrl() urls: this.getImageUrls(this.props)
}; };
}, },
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps: function(newProps) {
this.refreshImageUrl(); this.setState({
urls: this.getImageUrls(newProps)
})
}, },
refreshImageUrl: function(nextProps) { getImageUrls: function(props) {
// If the list has changed, we start from scratch and re-check, but return [
// don't do so unless the list has changed or we'd re-try fetching this.getRoomAvatarUrl(props), // highest priority
// images each time we re-rendered this.getOneToOneAvatar(props),
var newList = this.getUrlList(); this.getFallbackAvatar(props) // lowest priority
var differs = false; ].filter(function(url) {
for (var i = 0; i < newList.length && i < this.urlList.length; ++i) { return url != null;
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()
});
}
}, },
_update: function() { getRoomAvatarUrl: function(props) {
this.urlList = this.getUrlList(); return props.room.getAvatarUrl(
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(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
this.props.width, this.props.height, this.props.resizeMethod, props.width, props.height, props.resizeMethod,
false false
); );
return url;
}, },
// provided to the view class for convenience getOneToOneAvatar: function(props) {
getOneToOneAvatar: function() { var userIds = Object.keys(props.room.currentState.members);
var userIds = Object.keys(this.props.room.currentState.members);
if (userIds.length == 2) { if (userIds.length == 2) {
var theOtherGuy = null; var theOtherGuy = null;
if (this.props.room.currentState.members[userIds[0]].userId == MatrixClientPeg.get().credentials.userId) { if (props.room.currentState.members[userIds[0]].userId == MatrixClientPeg.get().credentials.userId) {
theOtherGuy = this.props.room.currentState.members[userIds[1]]; theOtherGuy = props.room.currentState.members[userIds[1]];
} else { } else {
theOtherGuy = this.props.room.currentState.members[userIds[0]]; theOtherGuy = props.room.currentState.members[userIds[0]];
} }
return theOtherGuy.getAvatarUrl( return theOtherGuy.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
this.props.width, this.props.height, this.props.resizeMethod, props.width, props.height, props.resizeMethod,
false false
); );
} else if (userIds.length == 1) { } 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(), MatrixClientPeg.get().getHomeserverUrl(),
this.props.width, this.props.height, this.props.resizeMethod, props.width, props.height, props.resizeMethod,
false false
); );
} else { } else {
@ -114,58 +92,15 @@ module.exports = React.createClass({
} }
}, },
getFallbackAvatar: function(props) {
onError: function(ev) { return Avatar.defaultAvatarUrlForString(props.room.roomId);
this.setState({
imageUrl: this._nextUrl()
});
},
////////////
getUrlList: function() {
return [
this.roomAvatarUrl(),
this.getOneToOneAvatar(),
this.getFallbackAvatar()
];
},
getFallbackAvatar: function() {
return Avatar.defaultAvatarUrlForString(this.props.room.roomId);
}, },
render: function() { render: function() {
var style = { var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
width: this.props.width, return (
height: this.props.height, <BaseAvatar {...this.props} name={this.props.room.name}
}; idName={this.props.room.roomId} urls={this.state.urls} />
);
// 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 (
<span>
<span className="mx_RoomAvatar_initial" aria-hidden="true"
style={{ fontSize: (this.props.width * 0.65) + "px",
width: this.props.width + "px",
lineHeight: this.props.height + "px" }}>{ initial }</span>
<img className="mx_RoomAvatar" src={this.state.imageUrl}
onError={this.onError} style={style} />
</span>
);
}
else {
return <img className="mx_RoomAvatar" src={this.state.imageUrl}
onError={this.onError} style={style} />
}
} }
}); });

View file

@ -18,13 +18,22 @@ limitations under the License.
var React = require('react'); var React = require('react');
const KEY_TAB = 9;
const KEY_SHIFT = 16;
const KEY_WINDOWS = 91;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'EditableText', displayName: 'EditableText',
propTypes: { propTypes: {
onValueChanged: React.PropTypes.func, onValueChanged: React.PropTypes.func,
initialValue: React.PropTypes.string, initialValue: React.PropTypes.string,
label: 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: { Phases: {
@ -36,38 +45,62 @@ module.exports = React.createClass({
return { return {
onValueChanged: function() {}, onValueChanged: function() {},
initialValue: '', initialValue: '',
label: 'Click to set', label: '',
placeholder: '', placeholder: '',
editable: true,
}; };
}, },
getInitialState: function() { getInitialState: function() {
return { return {
value: this.props.initialValue,
phase: this.Phases.Display, phase: this.Phases.Display,
} }
}, },
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps: function(nextProps) {
this.setState({ if (nextProps.initialValue !== this.props.initialValue) {
value: nextProps.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() { getValue: function() {
return this.state.value; return this.value;
}, },
setValue: function(val, shouldSubmit, suppressListener) { setValue: function(value) {
var self = this; this.value = value;
this.setState({ this.showPlaceholder(!this.value);
value: val,
phase: this.Phases.Display,
}, function() {
if (!suppressListener) {
self.onValueChanged(shouldSubmit);
}
});
}, },
edit: function() { edit: function() {
@ -80,65 +113,106 @@ module.exports = React.createClass({
this.setState({ this.setState({
phase: this.Phases.Display, phase: this.Phases.Display,
}); });
this.value = this.props.initialValue;
this.showPlaceholder(!this.value);
this.onValueChanged(false); this.onValueChanged(false);
}, },
onValueChanged: function(shouldSubmit) { 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) { 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") { if (ev.key == "Enter") {
this.onFinish(ev); this.onFinish(ev);
} else if (ev.key == "Escape") { } else if (ev.key == "Escape") {
this.cancelEdit(); 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({ this.setState({
phase: this.Phases.Edit, phase: this.Phases.Edit,
}) })
}, },
onFocus: function(ev) { onFocus: function(ev) {
ev.target.setSelectionRange(0, ev.target.value.length); //ev.target.setSelectionRange(0, ev.target.textContent.length);
},
onFinish: function(ev) { var node = ev.target.childNodes[0];
if (ev.target.value) { if (node) {
this.setValue(ev.target.value, ev.key === "Enter"); var range = document.createRange();
} else { range.setStart(node, 0);
this.cancelEdit(); range.setEnd(node, node.length);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
} }
}, },
onBlur: function() { onFinish: function(ev) {
this.cancelEdit(); 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() { render: function() {
var editable_el; var editable_el;
if (this.state.phase == this.Phases.Display) { if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) {
if (this.state.value) { // show the label
editable_el = <div ref="display_div" onClick={this.onClickDiv}>{this.state.value}</div>; editable_el = <div className={this.props.className + " " + this.props.labelClassName} onClick={this.onClickDiv}>{ this.props.label || this.props.initialValue }</div>;
} else { } else {
editable_el = <div ref="display_div" onClick={this.onClickDiv}>{this.props.label}</div>; // show the content editable div, but manually manage its contents as react and contentEditable don't play nice together
} editable_el = <div ref="editable_div" contentEditable="true" className={this.props.className}
} else if (this.state.phase == this.Phases.Edit) { onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur}></div>;
editable_el = (
<div>
<input type="text" defaultValue={this.state.value}
onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur} placeholder={this.props.placeHolder} autoFocus/>
</div>
);
} }
return ( return editable_el;
<div className="mx_EditableText">
{editable_el}
</div>
);
} }
}); });

View file

@ -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 = <span>{ this.props.value }</span>
}
else {
input = <input ref="custom" type="text" size="3" defaultValue={ this.props.value } onBlur={ this.onCustomBlur } onKeyDown={ this.onCustomKeyDown }/>
}
customPicker = <span> of { input }</span>;
}
var selectValue = roles[this.props.value] || "Custom";
var select;
if (this.props.disabled) {
select = <span>{ selectValue }</span>;
}
else {
select =
<select ref="select" defaultValue={ selectValue } onChange={ this.onSelectChange }>
<option value="User">User (0)</option>
<option value="Moderator">Moderator (50)</option>
<option value="Admin">Admin (100)</option>
<option value="Custom">Custom level</option>
</select>
}
return (
<span className="mx_PowerSelector">
{ select }
{ customPicker }
</span>
);
}
});

View file

@ -22,7 +22,8 @@ var ReactDOM = require('react-dom');
*/ */
module.exports = React.createClass({displayName: 'PasswordLogin', module.exports = React.createClass({displayName: 'PasswordLogin',
propTypes: { propTypes: {
onSubmit: React.PropTypes.func.isRequired // fn(username, password) onSubmit: React.PropTypes.func.isRequired, // fn(username, password)
onForgotPasswordClick: React.PropTypes.func // fn()
}, },
getInitialState: function() { getInitialState: function() {
@ -46,6 +47,16 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
}, },
render: function() { render: function() {
var forgotPasswordJsx;
if (this.props.onForgotPasswordClick) {
forgotPasswordJsx = (
<a className="mx_Login_forgot" onClick={this.props.onForgotPasswordClick} href="#">
Forgot your password?
</a>
);
}
return ( return (
<div> <div>
<form onSubmit={this.onSubmitForm}> <form onSubmit={this.onSubmitForm}>
@ -57,6 +68,7 @@ module.exports = React.createClass({displayName: 'PasswordLogin',
value={this.state.password} onChange={this.onPasswordChanged} value={this.state.password} onChange={this.onPasswordChanged}
placeholder="Password" /> placeholder="Password" />
<br /> <br />
{forgotPasswordJsx}
<input className="mx_Login_submit" type="submit" value="Log in" /> <input className="mx_Login_submit" type="submit" value="Log in" />
</form> </form>
</div> </div>

View file

@ -17,8 +17,15 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); var React = require('react');
var Velocity = require('velocity-animate');
require('velocity-ui-pack');
var sdk = require('../../../index'); var sdk = require('../../../index');
var FIELD_EMAIL = 'field_email';
var FIELD_USERNAME = 'field_username';
var FIELD_PASSWORD = 'field_password';
var FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
/** /**
* A pure UI component which displays a registration form. * A pure UI component which displays a registration form.
*/ */
@ -50,52 +57,151 @@ module.exports = React.createClass({
email: this.props.defaultEmail, email: this.props.defaultEmail,
username: this.props.defaultUsername, username: this.props.defaultUsername,
password: null, password: null,
passwordConfirm: null passwordConfirm: null,
fieldValid: {}
}; };
}, },
onSubmit: function(ev) { onSubmit: function(ev) {
ev.preventDefault(); ev.preventDefault();
var pwd1 = this.refs.password.value.trim(); // validate everything, in reverse order so
var pwd2 = this.refs.passwordConfirm.value.trim() // 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 (this.allFieldsValid()) {
if (!pwd1 || !pwd2) { var promise = this.props.onRegisterClick({
errCode = "RegistrationForm.ERR_PASSWORD_MISSING"; username: this.refs.username.value.trim(),
} password: this.refs.password.value.trim(),
else if (pwd1 !== pwd2) { email: this.refs.email.value.trim()
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 (promise) {
ev.target.disabled = true;
promise.finally(function() {
ev.target.disabled = false;
});
}
} }
}, },
/**
* Returns true if all fields were valid last time
* they were validated.
*/
allFieldsValid: function() {
var keys = Object.keys(this.state.fieldValid);
for (var i = 0; i < keys.length; ++i) {
if (this.state.fieldValid[keys[i]] == false) {
return false;
}
}
return true;
},
validateField: function(field_id) {
var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim()
switch (field_id) {
case FIELD_EMAIL:
this.markFieldValid(
field_id,
this.refs.email.value == '' || !!this.refs.email.value.match(/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i),
"RegistrationForm.ERR_EMAIL_INVALID"
);
break;
case FIELD_USERNAME:
// XXX: SPEC-1
if (encodeURIComponent(this.refs.username.value) != this.refs.username.value) {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_USERNAME_INVALID"
);
} else if (this.refs.username.value == '') {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_USERNAME_BLANK"
);
} else {
this.markFieldValid(field_id, true);
}
break;
case FIELD_PASSWORD:
if (pwd1 == '') {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_PASSWORD_MISSING"
);
} else if (pwd1.length < this.props.minPasswordLength) {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_PASSWORD_LENGTH"
);
} else {
this.markFieldValid(field_id, true);
}
break;
case FIELD_PASSWORD_CONFIRM:
this.markFieldValid(
field_id, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH"
);
break;
}
},
markFieldValid: function(field_id, val, error_code) {
var fieldValid = this.state.fieldValid;
fieldValid[field_id] = val;
this.setState({fieldValid: fieldValid});
if (!val) {
Velocity(this.fieldElementById(field_id), "callout.shake", 300);
this.props.onError(error_code);
}
},
fieldElementById(field_id) {
switch (field_id) {
case FIELD_EMAIL:
return this.refs.email;
case FIELD_USERNAME:
return this.refs.username;
case FIELD_PASSWORD:
return this.refs.password;
case FIELD_PASSWORD_CONFIRM:
return this.refs.passwordConfirm;
}
},
_styleField: function(field_id, baseStyle) {
var style = baseStyle || {};
if (this.state.fieldValid[field_id] === false) {
style['borderColor'] = 'red';
}
return style;
},
render: function() { render: function() {
var self = this;
var emailSection, registerButton; var emailSection, registerButton;
if (this.props.showEmail) { if (this.props.showEmail) {
emailSection = ( emailSection = (
<input className="mx_Login_field" type="text" ref="email" <input className="mx_Login_field" type="text" ref="email"
autoFocus={true} placeholder="Email address" autoFocus={true} placeholder="Email address"
defaultValue={this.state.email} /> defaultValue={this.state.email}
style={this._styleField(FIELD_EMAIL)}
onBlur={function() {self.validateField(FIELD_EMAIL)}} />
); );
} }
if (this.props.onRegisterClick) { if (this.props.onRegisterClick) {
@ -111,13 +217,19 @@ module.exports = React.createClass({
<br /> <br />
<input className="mx_Login_field" type="text" ref="username" <input className="mx_Login_field" type="text" ref="username"
placeholder="User name" defaultValue={this.state.username} placeholder="User name" defaultValue={this.state.username}
style={this._styleField(FIELD_USERNAME)}
onBlur={function() {self.validateField(FIELD_USERNAME)}}
disabled={this.props.disableUsernameChanges} /> disabled={this.props.disableUsernameChanges} />
<br /> <br />
<input className="mx_Login_field" type="password" ref="password" <input className="mx_Login_field" type="password" ref="password"
style={this._styleField(FIELD_PASSWORD)}
onBlur={function() {self.validateField(FIELD_PASSWORD)}}
placeholder="Password" defaultValue={this.state.password} /> placeholder="Password" defaultValue={this.state.password} />
<br /> <br />
<input className="mx_Login_field" type="password" ref="passwordConfirm" <input className="mx_Login_field" type="password" ref="passwordConfirm"
placeholder="Confirm password" placeholder="Confirm password"
style={this._styleField(FIELD_PASSWORD_CONFIRM)}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
defaultValue={this.state.passwordConfirm} /> defaultValue={this.state.passwordConfirm} />
<br /> <br />
{registerButton} {registerButton}

View file

@ -36,6 +36,9 @@ module.exports = React.createClass({
}, },
componentDidUpdate: function() { 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") if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
}, },

View file

@ -58,15 +58,16 @@ module.exports = React.createClass({
var roomId = this.props.member.roomId; var roomId = this.props.member.roomId;
var target = this.props.member.userId; var target = this.props.member.userId;
MatrixClientPeg.get().kick(roomId, target).done(function() { MatrixClientPeg.get().kick(roomId, target).done(function() {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Kick success"); console.log("Kick success");
}, function(err) { }, function(err) {
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Kick error", title: "Kick error",
description: err.message description: err.message
}); });
}); }
);
this.props.onFinished(); this.props.onFinished();
}, },
@ -74,16 +75,18 @@ module.exports = React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var roomId = this.props.member.roomId; var roomId = this.props.member.roomId;
var target = this.props.member.userId; var target = this.props.member.userId;
MatrixClientPeg.get().ban(roomId, target).done(function() { MatrixClientPeg.get().ban(roomId, target).done(
// NO-OP; rely on the m.room.member event coming down else we could function() {
// get out of sync if we force setState here! // NO-OP; rely on the m.room.member event coming down else we could
console.log("Ban success"); // get out of sync if we force setState here!
}, function(err) { console.log("Ban success");
Modal.createDialog(ErrorDialog, { }, function(err) {
title: "Ban error", Modal.createDialog(ErrorDialog, {
description: err.message title: "Ban error",
}); description: err.message
}); });
}
);
this.props.onFinished(); this.props.onFinished();
}, },
@ -118,16 +121,17 @@ module.exports = React.createClass({
} }
MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done( MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done(
function() { function() {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Mute toggle success"); console.log("Mute toggle success");
}, function(err) { }, function(err) {
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Mute error", title: "Mute error",
description: err.message description: err.message
}); });
}); }
);
this.props.onFinished(); this.props.onFinished();
}, },
@ -154,22 +158,55 @@ module.exports = React.createClass({
} }
var defaultLevel = powerLevelEvent.getContent().users_default; var defaultLevel = powerLevelEvent.getContent().users_default;
var modLevel = me.powerLevel - 1; var modLevel = me.powerLevel - 1;
if (modLevel > 50 && defaultLevel < 50) modLevel = 50; // try to stick with the vector level defaults
// toggle the level // toggle the level
var newLevel = this.state.isTargetMod ? defaultLevel : modLevel; var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done( MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
function() { function() {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Mod toggle success"); console.log("Mod toggle success");
}, function(err) { }, function(err) {
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Mod error", title: "Mod error",
description: err.message description: err.message
}); });
}); }
);
this.props.onFinished(); 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() { onChatClick: function() {
// check if there are any existing rooms with just us and them (1:1) // 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. // 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({ MatrixClientPeg.get().createRoom({
invite: [this.props.member.userId], invite: [this.props.member.userId],
preset: "private_chat" preset: "private_chat"
}).done(function(res) { }).done(
self.setState({ creatingRoom: false }); function(res) {
dis.dispatch({ self.setState({ creatingRoom: false });
action: 'view_room', dis.dispatch({
room_id: res.room_id action: 'view_room',
}); room_id: res.room_id
self.props.onFinished(); });
}, function(err) { self.props.onFinished();
self.setState({ creatingRoom: false }); }, function(err) {
console.error( self.setState({ creatingRoom: false });
"Failed to create room: %s", JSON.stringify(err) console.error(
); "Failed to create room: %s", JSON.stringify(err)
self.props.onFinished(); );
}); self.props.onFinished();
}
);
} }
}, },
@ -291,9 +330,15 @@ module.exports = React.createClass({
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
powerLevels.state_default powerLevels.state_default
); );
var levelToSend = (
(powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
powerLevels.events_default
);
can.kick = me.powerLevel >= powerLevels.kick; can.kick = me.powerLevel >= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban; can.ban = me.powerLevel >= powerLevels.ban;
can.mute = me.powerLevel >= editPowerLevel; can.mute = me.powerLevel >= editPowerLevel;
can.toggleMod = me.powerLevel > them.powerLevel && them.powerLevel >= levelToSend;
can.modifyLevel = me.powerLevel > them.powerLevel; can.modifyLevel = me.powerLevel > them.powerLevel;
return can; return can;
}, },
@ -317,12 +362,11 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var interactButton, kickButton, banButton, muteButton, giveModButton, spinner; var startChat, kickButton, banButton, muteButton, giveModButton, spinner;
if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) { if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
interactButton = <div className="mx_MemberInfo_field" onClick={this.onLeaveClick}>Leave room</div>; // FIXME: we're referring to a vector component from react-sdk
} var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile');
else { startChat = <BottomLeftMenuTile collapsed={ false } img="img/create-big.svg" label="Start chat" onClick={ this.onChatClick }/>
interactButton = <div className="mx_MemberInfo_field" onClick={this.onChatClick}>Start chat</div>;
} }
if (this.state.creatingRoom) { if (this.state.creatingRoom) {
@ -346,35 +390,56 @@ module.exports = React.createClass({
{muteLabel} {muteLabel}
</div>; </div>;
} }
if (this.state.can.modifyLevel) { if (this.state.can.toggleMod) {
var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod"; var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator";
giveModButton = <div className="mx_MemberInfo_field" onClick={this.onModToggle}> giveModButton = <div className="mx_MemberInfo_field" onClick={this.onModToggle}>
{giveOpLabel} {giveOpLabel}
</div> </div>
} }
// 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 =
<div>
<h3>Admin tools</h3>
<div className="mx_MemberInfo_buttons">
{muteButton}
{kickButton}
{banButton}
{giveModButton}
</div>
</div>
}
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
return ( return (
<div className="mx_MemberInfo"> <div className="mx_MemberInfo">
<img className="mx_MemberInfo_cancel" src="img/cancel.svg" width="18" height="18" onClick={this.onCancel}/> <img className="mx_MemberInfo_cancel" src="img/cancel.svg" width="18" height="18" onClick={this.onCancel}/>
<div className="mx_MemberInfo_avatar"> <div className="mx_MemberInfo_avatar">
<MemberAvatar member={this.props.member} width={48} height={48} /> <MemberAvatar member={this.props.member} width={48} height={48} />
</div> </div>
<h2>{ this.props.member.name }</h2> <h2>{ this.props.member.name }</h2>
<div className="mx_MemberInfo_profileField">
{ this.props.member.userId } <div className="mx_MemberInfo_profile">
</div> <div className="mx_MemberInfo_profileField">
<div className="mx_MemberInfo_profileField"> { this.props.member.userId }
power: { this.props.member.powerLevelNorm }% </div>
</div> <div className="mx_MemberInfo_profileField">
<div className="mx_MemberInfo_buttons"> Level: <b><PowerSelector value={ parseInt(this.props.member.powerLevel) } disabled={ !this.state.can.modifyLevel } onChange={ this.onPowerChange }/></b>
{interactButton} </div>
{muteButton}
{kickButton}
{banButton}
{giveModButton}
{spinner}
</div> </div>
{ startChat }
{ adminTools }
{ spinner }
</div> </div>
); );
} }

View file

@ -15,12 +15,19 @@ limitations under the License.
*/ */
var React = require('react'); var React = require('react');
var classNames = require('classnames'); var classNames = require('classnames');
var Matrix = require("matrix-js-sdk");
var q = require('q');
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal"); var Modal = require("../../../Modal");
var sdk = require('../../../index'); var sdk = require('../../../index');
var GeminiScrollbar = require('react-gemini-scrollbar'); var GeminiScrollbar = require('react-gemini-scrollbar');
var INITIAL_LOAD_NUM_MEMBERS = 50; 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({ module.exports = React.createClass({
displayName: 'MemberList', displayName: 'MemberList',
@ -131,12 +138,41 @@ module.exports = React.createClass({
return; 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) { if (isEmailAddress) {
promise = MatrixClientPeg.get().inviteByEmail(this.props.roomId, inputText); promise = promise.then(function() {
MatrixClientPeg.get().inviteByEmail(self.props.roomId, inputText);
});
} }
else { else {
promise = MatrixClientPeg.get().invite(this.props.roomId, inputText); promise = promise.then(function() {
MatrixClientPeg.get().invite(self.props.roomId, inputText);
});
} }
self.setState({ self.setState({
@ -151,11 +187,13 @@ module.exports = React.createClass({
inviting: false inviting: false
}); });
}, function(err) { }, function(err) {
console.error("Failed to invite: %s", JSON.stringify(err)); if (err !== null) {
Modal.createDialog(ErrorDialog, { console.error("Failed to invite: %s", JSON.stringify(err));
title: "Server error whilst inviting", Modal.createDialog(ErrorDialog, {
description: err.message title: "Server error whilst inviting",
}); description: err.message
});
}
self.setState({ self.setState({
inviting: false inviting: false
}); });
@ -229,7 +267,8 @@ module.exports = React.createClass({
var MemberTile = sdk.getComponent("rooms.MemberTile"); var MemberTile = sdk.getComponent("rooms.MemberTile");
var self = this; var self = this;
return self.state.members.filter(function(userId) {
var memberList = self.state.members.filter(function(userId) {
var m = self.memberDict[userId]; var m = self.memberDict[userId];
return m.membership == membership; return m.membership == membership;
}).map(function(userId) { }).map(function(userId) {
@ -238,6 +277,31 @@ module.exports = React.createClass({
<MemberTile key={userId} member={m} ref={userId} /> <MemberTile key={userId} member={m} ref={userId} />
); );
}); });
if (membership === "invite") {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (room) {
room.currentState.getStateEvents("m.room.third_party_invite").forEach(
function(e) {
// discard all invites which have a m.room.member event since we've
// already added them.
var memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey());
if (memberEvent) {
return;
}
memberList.push(
<MemberTile key={e.getStateKey()} ref={e.getStateKey()}
customDisplayName={e.getContent().display_name} />
)
})
}
}
return memberList;
}, },
onPopulateInvite: function(e) { onPopulateInvite: function(e) {
@ -254,7 +318,7 @@ module.exports = React.createClass({
} else { } else {
return ( return (
<form onSubmit={this.onPopulateInvite}> <form onSubmit={this.onPopulateInvite}>
<input className="mx_MemberList_invite" ref="invite" placeholder="Invite user (email)"/> <input className="mx_MemberList_invite" ref="invite" id="mx_MemberList_invite" placeholder="Invite user (email)"/>
</form> </form>
); );
} }

View file

@ -26,20 +26,19 @@ var Modal = require("../../../Modal");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MemberTile', displayName: 'MemberTile',
propTypes: {
member: React.PropTypes.any, // RoomMember
onFinished: React.PropTypes.func,
customDisplayName: React.PropTypes.string // for 3pid invites
},
getInitialState: function() { getInitialState: function() {
return {}; return {};
}, },
onLeaveClick: function() {
dis.dispatch({
action: 'leave_room',
room_id: this.props.member.roomId,
});
this.props.onFinished();
},
shouldComponentUpdate: function(nextProps, nextState) { shouldComponentUpdate: function(nextProps, nextState) {
if (this.state.hover !== nextState.hover) return true; if (this.state.hover !== nextState.hover) return true;
if (!this.props.member) { return false; } // e.g. 3pid members
if ( if (
this.member_last_modified_time === undefined || this.member_last_modified_time === undefined ||
this.member_last_modified_time < nextProps.member.getLastModifiedTime() this.member_last_modified_time < nextProps.member.getLastModifiedTime()
@ -65,117 +64,121 @@ module.exports = React.createClass({
}, },
onClick: function(e) { onClick: function(e) {
if (!this.props.member) { return; } // e.g. 3pid members
dis.dispatch({ dis.dispatch({
action: 'view_user', action: 'view_user',
member: this.props.member, member: this.props.member,
}); });
}, },
getDuration: function(time) { _getDisplayName: function() {
if (!time) return; if (this.props.customDisplayName) {
var t = parseInt(time / 1000); return this.props.customDisplayName;
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 this.props.member.name;
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";
}, },
getPowerLabel: function() { getPowerLabel: function() {
var label = this.props.member.userId; if (!this.props.member) {
if (this.state.isTargetMod) { return this._getDisplayName();
label += " - Mod (" + this.props.member.powerLevelNorm + "%)";
} }
var label = this.props.member.userId + " (power " + this.props.member.powerLevel + ")";
return label; return label;
}, },
render: function() { render: function() {
this.member_last_modified_time = this.props.member.getLastModifiedTime(); var member = this.props.member;
if (this.props.member.user) { var isMyUser = false;
this.user_last_modified_time = this.props.member.user.getLastModifiedTime(); var name = this._getDisplayName();
} var active = -1;
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 = <img src={ img } className="mx_MemberTile_power" width="44" height="44" alt=""/>;
// }
var presenceClass = "mx_MemberTile_offline"; var presenceClass = "mx_MemberTile_offline";
var mainClassName = "mx_MemberTile ";
if (this.props.member.user) { if (member) {
if (this.props.member.user.presence === "online") { if (member.user) {
presenceClass = "mx_MemberTile_online"; this.user_last_modified_time = member.user.getLastModifiedTime();
// FIXME: make presence data update whenever User.presence changes...
active = (
(Date.now() - (member.user.lastPresenceTs - member.user.lastActiveAgo)) || -1
);
if (member.user.presence === "online") {
presenceClass = "mx_MemberTile_online";
}
else if (member.user.presence === "unavailable") {
presenceClass = "mx_MemberTile_unavailable";
}
} }
else if (this.props.member.user.presence === "unavailable") { this.member_last_modified_time = member.getLastModifiedTime();
presenceClass = "mx_MemberTile_unavailable"; isMyUser = MatrixClientPeg.get().credentials.userId == member.userId;
// if (this.props.member && this.props.member.powerLevelNorm > 0) {
// var img = "img/p/p" + Math.floor(20 * this.props.member.powerLevelNorm / 100) + ".png";
// power = <img src={ img } className="mx_MemberTile_power" width="44" height="44" alt=""/>;
// }
var power;
if (this.props.member) {
var powerLevel = this.props.member.powerLevel;
if (powerLevel >= 50 && powerLevel < 99) {
power = <img src="img/mod.svg" className="mx_MemberTile_power" width="16" height="17" alt="Mod"/>;
}
if (powerLevel >= 99) {
power = <img src="img/admin.svg" className="mx_MemberTile_power" width="16" height="17" alt="Admin"/>;
}
} }
} }
var mainClassName = "mx_MemberTile ";
mainClassName += presenceClass; mainClassName += presenceClass;
if (this.state.hover) { if (this.state.hover) {
mainClassName += " mx_MemberTile_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 ? <img className="mx_MemberTile_leave" src="img/delete.png" width="10" height="10" onClick={this.onLeaveClick}/> : null;
var nameEl; var nameEl;
if (this.state.hover) { if (this.state.hover && this.props.member) {
var presence; var presenceState = (member && member.user) ? member.user.presence : null;
// FIXME: make presence data update whenever User.presence changes... var PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
var active = this.props.member.user ? ((Date.now() - (this.props.member.user.lastPresenceTs - this.props.member.user.lastActiveAgo)) || -1) : -1; nameEl = (
if (active >= 0) {
presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) } { this.getDuration(active) } ago</div>;
}
else {
presence = <div className="mx_MemberTile_presence">{ this.getPrettyPresence(this.props.member.user) }</div>;
}
nameEl =
<div className="mx_MemberTile_details"> <div className="mx_MemberTile_details">
<img className="mx_MemberTile_chevron" src="img/member_chevron.png" width="8" height="12"/> <img className="mx_MemberTile_chevron" src="img/member_chevron.png" width="8" height="12"/>
<div className="mx_MemberTile_userId">{ name }</div> <div className="mx_MemberTile_userId">{ name }</div>
{ presence } <PresenceLabel activeAgo={active}
presenceState={presenceState} />
</div> </div>
);
} }
else { else {
nameEl = nameEl = (
<div className="mx_MemberTile_name"> <div className="mx_MemberTile_name">
{ name } { name }
</div> </div>
);
} }
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var av;
if (member) {
av = (
<MemberAvatar member={this.props.member} width={36} height={36} />
);
}
else {
av = (
<BaseAvatar name={name} width={36} height={36} />
);
}
return ( return (
<div className={mainClassName} title={ this.getPowerLabel() } <div className={mainClassName} title={ this.getPowerLabel() }
onClick={ this.onClick } onMouseEnter={ this.mouseEnter } onClick={ this.onClick } onMouseEnter={ this.mouseEnter }
onMouseLeave={ this.mouseLeave }> onMouseLeave={ this.mouseLeave }>
<div className="mx_MemberTile_avatar"> <div className="mx_MemberTile_avatar">
<MemberAvatar member={this.props.member} width={36} height={36} /> { av }
{ power } { power }
</div> </div>
{ nameEl } { nameEl }
</div> </div>

View file

@ -75,7 +75,7 @@ module.exports = React.createClass({
// a callback which is called when the height of the composer is // a callback which is called when the height of the composer is
// changed due to a change in content. // changed due to a change in content.
onResize: React.PropTypes.function, onResize: React.PropTypes.func,
}, },
componentWillMount: function() { componentWillMount: function() {
@ -209,23 +209,18 @@ module.exports = React.createClass({
this.sentHistory.push(input); this.sentHistory.push(input);
this.onEnter(ev); this.onEnter(ev);
} }
else if (ev.keyCode === KeyCode.UP) { else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var input = this.refs.textarea.value; var oldSelectionStart = this.refs.textarea.selectionStart;
var offset = this.refs.textarea.selectionStart || 0; // Remember the keyCode because React will recycle the synthetic event
if (ev.ctrlKey || !input.substr(0, offset).match(/\n/)) { var keyCode = ev.keyCode;
this.sentHistory.next(1); // set a callback so we can see if the cursor position changes as
ev.preventDefault(); // a result of this event. If it doesn't, we cycle history.
this.resizeInput(); setTimeout(() => {
} if (this.refs.textarea.selectionStart == oldSelectionStart) {
} this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
else if (ev.keyCode === KeyCode.DOWN) { this.resizeInput();
var input = this.refs.textarea.value; }
var offset = this.refs.textarea.selectionStart || 0; }, 0);
if (ev.ctrlKey || !input.substr(offset).match(/\n/)) {
this.sentHistory.next(-1);
ev.preventDefault();
this.resizeInput();
}
} }
if (this.props.tabComplete) { if (this.props.tabComplete) {
@ -341,7 +336,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
} }
sendMessagePromise.then(function() { sendMessagePromise.done(function() {
dis.dispatch({ dis.dispatch({
action: 'message_sent' action: 'message_sent'
}); });

View file

@ -0,0 +1,84 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
module.exports = React.createClass({
displayName: 'PresenceLabel',
propTypes: {
activeAgo: React.PropTypes.number,
presenceState: React.PropTypes.string
},
getDefaultProps: function() {
return {
ago: -1,
presenceState: null
};
},
getDuration: function(time) {
if (!time) return;
var t = parseInt(time / 1000);
var s = t % 60;
var m = parseInt(t / 60) % 60;
var h = parseInt(t / (60 * 60)) % 24;
var d = parseInt(t / (60 * 60 * 24));
if (t < 60) {
if (t < 0) {
return "0s";
}
return s + "s";
}
if (t < 60 * 60) {
return m + "m";
}
if (t < 24 * 60 * 60) {
return h + "h";
}
return d + "d ";
},
getPrettyPresence: function(presence) {
if (presence === "online") return "Online";
if (presence === "unavailable") return "Idle"; // XXX: is this actually right?
if (presence === "offline") return "Offline";
return "Unknown";
},
render: function() {
if (this.props.activeAgo >= 0) {
return (
<div className="mx_PresenceLabel">
{ this.getPrettyPresence(this.props.presenceState) } { this.getDuration(this.props.activeAgo) } ago
</div>
);
}
else {
return (
<div className="mx_PresenceLabel">
{ this.getPrettyPresence(this.props.presenceState) }
</div>
);
}
}
});

View file

@ -21,6 +21,12 @@ var sdk = require('../../../index');
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix');
linkifyMatrix(linkify);
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomHeader', displayName: 'RoomHeader',
@ -41,6 +47,25 @@ module.exports = React.createClass({
}; };
}, },
componentWillReceiveProps: function(newProps) {
if (newProps.editing) {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
var name = this.props.room.currentState.getStateEvents('m.room.name', '');
this.setState({
name: name ? name.getContent().name : '',
defaultName: this.props.room.getDefaultRoomName(MatrixClientPeg.get().credentials.userId),
topic: topic ? topic.getContent().topic : '',
});
}
},
componentDidUpdate: function() {
if (this.refs.topic) {
linkifyElement(this.refs.topic, linkifyMatrix.options);
}
},
onVideoClick: function(e) { onVideoClick: function(e) {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
@ -57,26 +82,59 @@ module.exports = React.createClass({
}); });
}, },
onNameChange: function(new_name) { onNameChanged: function(value) {
if (this.props.room.name != new_name && new_name) { this.setState({ name : value });
MatrixClientPeg.get().setRoomName(this.props.room.roomId, new_name); },
onTopicChanged: function(value) {
this.setState({ topic : value });
},
onAvatarPickerClick: function(ev) {
if (this.refs.file_label) {
this.refs.file_label.click();
} }
}, },
onAvatarSelected: function(ev) {
var self = this;
var changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!");
return;
}
changeAvatar.onFileSelected(ev).done(function() {
// dunno if the avatar changed, re-check it.
self._refreshFromServer();
}, function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: "Failed to set avatar. " + errMsg
});
});
},
getRoomName: function() { getRoomName: function() {
return this.refs.name_edit.value; return this.state.name;
},
getTopic: function() {
return this.state.topic;
}, },
render: function() { render: function() {
var EditableText = sdk.getComponent("elements.EditableText"); var EditableText = sdk.getComponent("elements.EditableText");
var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); var RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); var TintableSvg = sdk.getComponent("elements.TintableSvg");
var header; var header;
if (this.props.simpleHeader) { if (this.props.simpleHeader) {
var cancel; var cancel;
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
cancel = <img className="mx_RoomHeader_simpleHeaderCancel" src="img/cancel-black.png" onClick={ this.props.onCancelClick } alt="Close" width="18" height="18"/> cancel = <img className="mx_RoomHeader_simpleHeaderCancel" src="img/cancel.svg" onClick={ this.props.onCancelClick } alt="Close" width="18" height="18"/>
} }
header = header =
<div className="mx_RoomHeader_wrapper"> <div className="mx_RoomHeader_wrapper">
@ -87,27 +145,72 @@ module.exports = React.createClass({
</div> </div>
} }
else { else {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
var name = null; var name = null;
var searchStatus = null; var searchStatus = null;
var topic_el = null; var topic_el = null;
var cancel_button = null; var cancel_button = null;
var save_button = null; var save_button = null;
var settings_button = null; var settings_button = null;
var actual_name = this.props.room.currentState.getStateEvents('m.room.name', '');
if (actual_name) actual_name = actual_name.getContent().name;
if (this.props.editing) { if (this.props.editing) {
name =
<div className="mx_RoomHeader_nameEditing">
<input className="mx_RoomHeader_nameInput" type="text" defaultValue={actual_name} placeholder="Name" ref="name_edit"/>
</div>
// if (topic) topic_el = <div className="mx_RoomHeader_topic"><textarea>{ topic.getContent().topic }</textarea></div>
cancel_button = <div className="mx_RoomHeader_textButton" onClick={this.props.onCancelClick}>Cancel</div>
save_button = <div className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save Changes</div>
} else {
// <EditableText label={this.props.room.name} initialValue={actual_name} placeHolder="Name" onValueChanged={this.onNameChange} />
// calculate permissions. XXX: this should be done on mount or something, and factored out with RoomSettings
var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', '');
var events_levels = (power_levels ? power_levels.events : {}) || {};
var user_id = MatrixClientPeg.get().credentials.userId;
if (power_levels) {
power_levels = power_levels.getContent();
var default_user_level = parseInt(power_levels.users_default || 0);
var user_levels = power_levels.users || {};
var current_user_level = user_levels[user_id];
if (current_user_level == undefined) current_user_level = default_user_level;
} else {
var default_user_level = 0;
var user_levels = [];
var current_user_level = 0;
}
var state_default = parseInt((power_levels ? power_levels.state_default : 0) || 0);
var room_avatar_level = state_default;
if (events_levels['m.room.avatar'] !== undefined) {
room_avatar_level = events_levels['m.room.avatar'];
}
var can_set_room_avatar = current_user_level >= room_avatar_level;
var room_name_level = state_default;
if (events_levels['m.room.name'] !== undefined) {
room_name_level = events_levels['m.room.name'];
}
var can_set_room_name = current_user_level >= room_name_level;
var room_topic_level = state_default;
if (events_levels['m.room.topic'] !== undefined) {
room_topic_level = events_levels['m.room.topic'];
}
var can_set_room_topic = current_user_level >= room_topic_level;
var placeholderName = "Unnamed Room";
if (this.state.defaultName && this.state.defaultName !== '?') {
placeholderName += " (" + this.state.defaultName + ")";
}
save_button = <div className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save</div>
cancel_button = <div className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </div>
}
if (can_set_room_name) {
name =
<div className="mx_RoomHeader_name">
<EditableText
className="mx_RoomHeader_nametext mx_RoomHeader_editable"
placeholderClassName="mx_RoomHeader_placeholder"
placeholder={ placeholderName }
blurToCancel={ false }
onValueChanged={ this.onNameChanged }
initialValue={ this.state.name }/>
</div>
}
else {
var searchStatus; var searchStatus;
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount. // gives us a valid (possibly zero) searchCount.
@ -123,14 +226,48 @@ module.exports = React.createClass({
<TintableSvg src="img/settings.svg" width="12" height="12"/> <TintableSvg src="img/settings.svg" width="12" height="12"/>
</div> </div>
</div> </div>
if (topic) topic_el = <div className="mx_RoomHeader_topic" title={topic.getContent().topic}>{ topic.getContent().topic }</div>; }
if (can_set_room_topic) {
topic_el =
<EditableText
className="mx_RoomHeader_topic mx_RoomHeader_editable"
placeholderClassName="mx_RoomHeader_placeholder"
placeholder="Add a topic"
blurToCancel={ false }
onValueChanged={ this.onTopicChanged }
initialValue={ this.state.topic }/>
} else {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (topic) topic_el = <div className="mx_RoomHeader_topic" ref="topic" title={ topic.getContent().topic }>{ topic.getContent().topic }</div>;
} }
var roomAvatar = null; var roomAvatar = null;
if (this.props.room) { if (this.props.room) {
roomAvatar = ( if (can_set_room_avatar) {
<RoomAvatar room={this.props.room} width="48" height="48" /> roomAvatar = (
); <div className="mx_RoomHeader_avatarPicker">
<div onClick={ this.onAvatarPickerClick }>
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={48} height={48} />
</div>
<div className="mx_RoomHeader_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label">
<img src="img/camera.svg"
alt="Upload avatar" title="Upload avatar"
width="17" height="15" />
</label>
<input id="avatarInput" type="file" onChange={ this.onAvatarSelected }/>
</div>
</div>
);
}
else {
roomAvatar = (
<div onClick={this.props.onSettingsClick}>
<RoomAvatar room={this.props.room} width={48} height={48}/>
</div>
);
}
} }
var leave_button; var leave_button;
@ -149,6 +286,18 @@ module.exports = React.createClass({
</div>; </div>;
} }
var right_row;
if (!this.props.editing) {
right_row =
<div className="mx_RoomHeader_rightRow">
{ forget_button }
{ leave_button }
<div className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title="Search">
<TintableSvg src="img/search.svg" width="21" height="19"/>
</div>
</div>;
}
header = header =
<div className="mx_RoomHeader_wrapper"> <div className="mx_RoomHeader_wrapper">
<div className="mx_RoomHeader_leftRow"> <div className="mx_RoomHeader_leftRow">
@ -160,20 +309,14 @@ module.exports = React.createClass({
{ topic_el } { topic_el }
</div> </div>
</div> </div>
{cancel_button}
{save_button} {save_button}
<div className="mx_RoomHeader_rightRow"> {cancel_button}
{ forget_button } {right_row}
{ leave_button }
<div className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title="Search">
<TintableSvg src="img/search.svg" width="21" height="19"/>
</div>
</div>
</div> </div>
} }
return ( return (
<div className="mx_RoomHeader"> <div className={ "mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "") }>
{ header } { header }
</div> </div>
); );

View file

@ -0,0 +1,56 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
module.exports = React.createClass({
displayName: 'RoomPreviewBar',
propTypes: {
onJoinClick: React.PropTypes.func,
canJoin: React.PropTypes.bool
},
getDefaultProps: function() {
return {
onJoinClick: function() {},
canJoin: false
};
},
render: function() {
var joinBlock;
if (this.props.canJoin) {
joinBlock = (
<div className="mx_RoomPreviewBar_join_text">
Would you like to <a onClick={this.props.onJoinClick}>join</a> this room?
</div>
);
}
return (
<div className="mx_RoomPreviewBar">
<div className="mx_RoomPreviewBar_preview_text">
This is a preview of this room. Room interactions have been disabled.
</div>
{joinBlock}
</div>
);
}
});

View file

@ -16,21 +16,98 @@ limitations under the License.
var React = require('react'); var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var Tinter = require('../../../Tinter');
var sdk = require('../../../index'); 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({ module.exports = React.createClass({
displayName: 'RoomSettings', displayName: 'RoomSettings',
propTypes: { propTypes: {
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
onSaveClick: React.PropTypes.func,
onCancelClick: React.PropTypes.func,
},
componentDidMount: function() {
// XXX: dirty hack to gutwrench to focus on the invite box
if (this.props.room.getJoinedMembers().length == 1) {
var inviteBox = document.getElementById("mx_MemberList_invite");
if (inviteBox) setTimeout(function() { inviteBox.focus(); }, 0);
}
}, },
getInitialState: function() { 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 { 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() { canGuestsJoin: function() {
return this.refs.guests_join.checked; return this.refs.guests_join.checked;
}, },
@ -40,7 +117,7 @@ module.exports = React.createClass({
}, },
getTopic: function() { getTopic: function() {
return this.refs.topic.value; return this.refs.topic ? this.refs.topic.value : "";
}, },
getJoinRules: function() { getJoinRules: function() {
@ -62,13 +139,13 @@ module.exports = React.createClass({
power_levels = power_levels.getContent(); power_levels = power_levels.getContent();
var new_power_levels = { var new_power_levels = {
ban: parseInt(this.refs.ban.value), ban: parseInt(this.refs.ban.getValue()),
kick: parseInt(this.refs.kick.value), kick: parseInt(this.refs.kick.getValue()),
redact: parseInt(this.refs.redact.value), redact: parseInt(this.refs.redact.getValue()),
invite: parseInt(this.refs.invite.value), invite: parseInt(this.refs.invite.getValue()),
events_default: parseInt(this.refs.events_default.value), events_default: parseInt(this.refs.events_default.getValue()),
state_default: parseInt(this.refs.state_default.value), state_default: parseInt(this.refs.state_default.getValue()),
users_default: parseInt(this.refs.users_default.value), users_default: parseInt(this.refs.users_default.getValue()),
users: power_levels.users, users: power_levels.users,
events: power_levels.events, events: power_levels.events,
}; };
@ -76,17 +153,231 @@ module.exports = React.createClass({
return new_power_levels; 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() { onPowerLevelsChanged: function() {
this.setState({ this.setState({
power_levels_changed: true power_levels_changed: true
}); });
}, },
render: function() { getColorScheme: function() {
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); if (!this.state.color_scheme_changed) return undefined;
var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); return {
if (topic) topic = topic.getContent().topic; primary_color: room_colors[this.state.color_scheme_index][0],
secondary_color: room_colors[this.state.color_scheme_index][1],
};
},
onColorSchemeChanged: function(index) {
// preview what the user just changed the scheme to.
Tinter.tint(room_colors[index][0], room_colors[index][1]);
this.setState({
color_scheme_changed: true,
color_scheme_index: index,
});
},
onAliasChanged: function(domain, index, alias) {
if (alias === "") return; // hit the delete button to delete please
var oldAlias;
if (this.isAliasValid(alias)) {
oldAlias = this.state.aliases[domain][index];
this.state.aliases[domain][index] = alias;
this.setState({ aliases_changed : true });
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid alias format",
description: "'" + alias + "' is not a valid format for an alias",
});
}
},
onAliasDeleted: function(domain, index) {
// It's a bit naughty to directly manipulate this.state, and React would
// normally whine at you, but it can't see us doing the splice. Given we
// promptly setState anyway, it's just about acceptable. The alternative
// would be to arbitrarily deepcopy to a temp variable and then setState
// that, but why bother when we can cut this corner.
var alias = this.state.aliases[domain].splice(index, 1);
this.setState({
aliases: this.state.aliases
});
this.setState({ aliases_changed : true });
},
onAliasAdded: function(alias) {
if (alias === "") return; // ignore attempts to create blank aliases
if (alias === undefined) {
alias = this.refs.add_alias ? this.refs.add_alias.getValue() : undefined;
if (alias === undefined || alias === "") return;
}
if (this.isAliasValid(alias)) {
var domain = alias.replace(/^.*?:/, '');
// XXX: do we need to deep copy aliases before editing it?
this.state.aliases[domain] = this.state.aliases[domain] || [];
this.state.aliases[domain].push(alias);
this.setState({
aliases: this.state.aliases
});
// reset the add field
this.refs.add_alias.setValue('');
this.setState({ aliases_changed : true });
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid alias format",
description: "'" + alias + "' is not a valid format for an alias",
});
}
},
isAliasValid: function(alias) {
// XXX: FIXME SPEC-1
return (alias.match(/^#([^\/:,]+?):(.+)$/) && encodeURI(alias) === alias);
},
onTagChange: function(tagName, event) {
if (event.target.checked) {
if (tagName === 'm.favourite') {
delete this.state.tags['m.lowpriority'];
}
else if (tagName === 'm.lowpriority') {
delete this.state.tags['m.favourite'];
}
this.state.tags[tagName] = this.state.tags[tagName] || {};
}
else {
delete this.state.tags[tagName];
}
// XXX: hacky say to deep-edit state
this.setState({
tags: this.state.tags,
tags_changed: true
});
},
render: function() {
// TODO: go through greying out things you don't have permission to change
// (or turning them into informative stuff)
var EditableText = sdk.getComponent('elements.EditableText');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', ''); var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', '');
if (join_rule) join_rule = join_rule.getContent().join_rule; if (join_rule) join_rule = join_rule.getContent().join_rule;
@ -108,7 +399,10 @@ module.exports = React.createClass({
} }
} }
var events_levels = power_levels.events || {}; var events_levels = (power_levels ? power_levels.events : {}) || {};
var user_id = MatrixClientPeg.get().credentials.userId;
if (power_levels) { if (power_levels) {
power_levels = power_levels.getContent(); power_levels = power_levels.getContent();
@ -127,8 +421,6 @@ module.exports = React.createClass({
var user_levels = power_levels.users || {}; var user_levels = power_levels.users || {};
var user_id = MatrixClientPeg.get().credentials.userId;
var current_user_level = user_levels[user_id]; var current_user_level = user_levels[user_id];
if (current_user_level == undefined) current_user_level = default_user_level; if (current_user_level == undefined) current_user_level = default_user_level;
@ -157,117 +449,308 @@ module.exports = React.createClass({
var can_change_levels = false; var can_change_levels = false;
} }
var room_avatar_level = parseInt(power_levels.state_default || 0); var state_default = (parseInt(power_levels ? power_levels.state_default : 0) || 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 change_avatar; var room_aliases_level = state_default;
if (can_set_room_avatar) { if (events_levels['m.room.aliases'] !== undefined) {
change_avatar = <div> room_avatar_level = events_levels['m.room.aliases'];
<h3>Room Icon</h3> }
<ChangeAvatar room={this.props.room} /> 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 =
<div>
<div className="mx_RoomSettings_aliasLabel">
This room can be found elsewhere as:
</div>
<div className="mx_RoomSettings_aliasesTable">
{ remote_domains.map(function(state_key, i) {
self.state.aliases[state_key].map(function(alias, j) {
return (
<div className="mx_RoomSettings_aliasesTableRow" key={ i + "_" + j }>
<EditableText
className="mx_RoomSettings_alias mx_RoomSettings_editable"
blurToCancel={ false }
editable={ false }
initialValue={ alias } />
<div className="mx_RoomSettings_deleteAlias">
</div>
</div>
);
});
})}
</div>
</div>
}
var canonical_alias_section;
if (can_set_canonical_alias) {
canonical_alias_section =
<select ref="canonical_alias" defaultValue={ canonical_alias }>
{ Object.keys(self.state.aliases).map(function(stateKey, i) {
return self.state.aliases[stateKey].map(function(alias, j) {
return <option value={ alias } key={ i + "_" + j }>{ alias }</option>
});
})}
<option value="" key="unset">not set</option>
</select>
}
else {
canonical_alias_section = <b>{ canonical_alias || "not set" }</b>;
}
var aliases_section =
<div>
<h3>Directory</h3>
<div className="mx_RoomSettings_aliasLabel">
{ this.state.aliases[domain].length
? "This room can be found on " + domain + " as:"
: "This room is not findable on " + domain }
</div>
<div className="mx_RoomSettings_aliasesTable">
{ this.state.aliases[domain].map(function(alias, i) {
var deleteButton;
if (can_set_room_aliases) {
deleteButton = <img src="img/cancel-small.svg" width="14" height="14" alt="Delete" onClick={ self.onAliasDeleted.bind(self, domain, i) }/>;
}
return (
<div className="mx_RoomSettings_aliasesTableRow" key={ i }>
<EditableText
className="mx_RoomSettings_alias mx_RoomSettings_editable"
placeholderClassName="mx_RoomSettings_aliasPlaceholder"
placeholder={ "New alias (e.g. #foo:" + domain + ")" }
blurToCancel={ false }
onValueChanged={ self.onAliasChanged.bind(self, domain, i) }
editable={ can_set_room_aliases }
initialValue={ alias } />
<div className="mx_RoomSettings_deleteAlias">
{ deleteButton }
</div>
</div>
);
})}
<div className="mx_RoomSettings_aliasesTableRow" key="new">
<EditableText
ref="add_alias"
className="mx_RoomSettings_alias mx_RoomSettings_editable"
placeholderClassName="mx_RoomSettings_aliasPlaceholder"
placeholder={ "New alias (e.g. #foo:" + domain + ")" }
blurToCancel={ false }
onValueChanged={ self.onAliasAdded } />
<div className="mx_RoomSettings_addAlias">
<img src="img/plus.svg" width="14" height="14" alt="Add" onClick={ self.onAliasAdded.bind(self, undefined) }/>
</div>
</div>
</div>
{ remote_aliases_section }
<div className="mx_RoomSettings_aliasLabel">The official way to refer to this room is: { canonical_alias_section }</div>
</div>; </div>;
var room_colors_section =
<div>
<h3>Room Colour</h3>
<div className="mx_RoomSettings_roomColors">
{room_colors.map(function(room_color, i) {
var selected;
if (i === self.state.color_scheme_index) {
selected =
<div className="mx_RoomSettings_roomColor_selected">
<img src="img/tick.svg" width="17" height="14" alt="./"/>
</div>
}
var boundClick = self.onColorSchemeChanged.bind(self, i)
return (
<div className="mx_RoomSettings_roomColor"
key={ "room_color_" + i }
style={{ backgroundColor: room_color[1] }}
onClick={ boundClick }>
{ selected }
<div className="mx_RoomSettings_roomColorPrimary" style={{ backgroundColor: room_color[0] }}></div>
</div>
);
})}
</div>
</div>;
var user_levels_section;
if (user_levels.length) {
user_levels_section =
<div>
<div>
Users with specific roles are:
</div>
<div>
{Object.keys(user_levels).map(function(user, i) {
return (
<div className="mx_RoomSettings_userLevel" key={user}>
{ user } is a
<PowerSelector value={ user_levels[user] } disabled={true}/>
</div>
);
})}
</div>
</div>;
}
else {
user_levels_section = <div>No users have specific privileges in this room.</div>
} }
var banned = this.props.room.getMembersWithMembership("ban"); var banned = this.props.room.getMembersWithMembership("ban");
var banned_users_section;
if (banned.length) {
banned_users_section =
<div>
<h3>Banned users</h3>
<div className="mx_RoomSettings_banned">
{banned.map(function(member, i) {
return (
<div key={i}>
{member.userId}
</div>
);
})}
</div>
</div>;
}
var create_event = this.props.room.currentState.getStateEvents('m.room.create', '');
var unfederatable_section;
if (create_event.getContent()["m.federate"] === false) {
unfederatable_section = <div className="mx_RoomSettings_powerLevel">Ths room is not accessible by remote Matrix servers.</div>
}
// 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 =
<div className="mx_RoomSettings_tags">
This room is tagged as
{ can_set_tag ?
tags.map(function(tag, i) {
return (<label key={ i }>
<input type="checkbox"
ref={ tag.ref }
checked={ tag.name in self.state.tags }
onChange={ self.onTagChange.bind(self, tag.name) }/>
{ tag.label }
</label>);
}) : tags.map(function(tag) { return tag.label; }).join(", ")
}
</div>
return ( return (
<div className="mx_RoomSettings"> <div className="mx_RoomSettings">
<textarea className="mx_RoomSettings_description" placeholder="Topic" defaultValue={topic} ref="topic"/> <br/>
<label><input type="checkbox" ref="is_private" defaultChecked={join_rule != "public"}/> Make this room private</label> <br/> <label><input type="checkbox" ref="is_private" defaultChecked={join_rule != "public"}/> Make this room private</label> <br/>
<label><input type="checkbox" ref="share_history" defaultChecked={history_visibility == "shared"}/> Share message history with new users</label> <br/> <label><input type="checkbox" ref="share_history" defaultChecked={history_visibility == "shared"}/> Share message history with new users</label> <br/>
<label> <label><input type="checkbox" ref="guests_read" defaultChecked={history_visibility === "world_readable"}/> Allow guests to read messages in this room</label> <br/>
<input type="checkbox" ref="guests_read" defaultChecked={history_visibility === "world_readable"}/> <label><input type="checkbox" ref="guests_join" defaultChecked={guest_access === "can_join"}/> Allow guests to join this room</label> <br/>
Allow guests to read messages in this room <label className="mx_RoomSettings_encrypt"><input type="checkbox" /> Encrypt room</label>
</label> <br/>
<label> { tags_section }
<input type="checkbox" ref="guests_join" defaultChecked={guest_access === "can_join"}/>
Allow guests to join this room { room_colors_section }
</label> <br/>
<label className="mx_RoomSettings_encrypt"><input type="checkbox" /> Encrypt room</label> <br/> { aliases_section }
<h3>Notifications</h3> <h3>Notifications</h3>
<div className="mx_RoomSettings_settings"> <div className="mx_RoomSettings_settings">
<label><input type="checkbox" ref="are_notifications_muted" defaultChecked={are_notifications_muted}/> Mute notifications for this room</label> <label><input type="checkbox" ref="are_notifications_muted" defaultChecked={are_notifications_muted}/> Mute notifications for this room</label>
</div> </div>
<h3>Power levels</h3> <h3>Permissions</h3>
<div className="mx_RoomSettings_power_levels mx_RoomSettings_settings"> <div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings">
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_ban_level">Ban level</label> <span className="mx_RoomSettings_powerLevelKey">The default role for new room members is </span>
<input type="text" defaultValue={ban_level} size="3" ref="ban" id="mx_RoomSettings_ban_level" <PowerSelector ref="users_default" value={default_user_level} disabled={!can_change_levels || current_user_level < default_user_level} onChange={this.onPowerLevelsChanged}/>
disabled={!can_change_levels || current_user_level < ban_level} onChange={this.onPowerLevelsChanged}/>
</div> </div>
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_kick_level">Kick level</label> <span className="mx_RoomSettings_powerLevelKey">To send messages, you must be a </span>
<input type="text" defaultValue={kick_level} size="3" ref="kick" id="mx_RoomSettings_kick_level" <PowerSelector ref="events_default" value={send_level} disabled={!can_change_levels || current_user_level < send_level} onChange={this.onPowerLevelsChanged}/>
disabled={!can_change_levels || current_user_level < kick_level} onChange={this.onPowerLevelsChanged}/>
</div> </div>
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_redact_level">Redact level</label> <span className="mx_RoomSettings_powerLevelKey">To invite users into the room, you must be a </span>
<input type="text" defaultValue={redact_level} size="3" ref="redact" id="mx_RoomSettings_redact_level" <PowerSelector ref="invite" value={invite_level} disabled={!can_change_levels || current_user_level < invite_level} onChange={this.onPowerLevelsChanged}/>
disabled={!can_change_levels || current_user_level < redact_level} onChange={this.onPowerLevelsChanged}/>
</div> </div>
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_invite_level">Invite level</label> <span className="mx_RoomSettings_powerLevelKey">To configure the room, you must be a </span>
<input type="text" defaultValue={invite_level} size="3" ref="invite" id="mx_RoomSettings_invite_level" <PowerSelector ref="state_default" value={state_level} disabled={!can_change_levels || current_user_level < state_level} onChange={this.onPowerLevelsChanged}/>
disabled={!can_change_levels || current_user_level < invite_level} onChange={this.onPowerLevelsChanged}/>
</div> </div>
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_event_level">Send event level</label> <span className="mx_RoomSettings_powerLevelKey">To kick users, you must be a </span>
<input type="text" defaultValue={send_level} size="3" ref="events_default" id="mx_RoomSettings_event_level" <PowerSelector ref="kick" value={kick_level} disabled={!can_change_levels || current_user_level < kick_level} onChange={this.onPowerLevelsChanged}/>
disabled={!can_change_levels || current_user_level < send_level} onChange={this.onPowerLevelsChanged}/>
</div> </div>
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_state_level">Set state level</label> <span className="mx_RoomSettings_powerLevelKey">To ban users, you must be a </span>
<input type="text" defaultValue={state_level} size="3" ref="state_default" id="mx_RoomSettings_state_level" <PowerSelector ref="ban" value={ban_level} disabled={!can_change_levels || current_user_level < ban_level} onChange={this.onPowerLevelsChanged}/>
disabled={!can_change_levels || current_user_level < state_level} onChange={this.onPowerLevelsChanged}/>
</div> </div>
<div> <div className="mx_RoomSettings_powerLevel">
<label htmlFor="mx_RoomSettings_user_level">Default user level</label> <span className="mx_RoomSettings_powerLevelKey">To redact messages, you must be a </span>
<input type="text" defaultValue={default_user_level} size="3" ref="users_default" <PowerSelector ref="redact" value={redact_level} disabled={!can_change_levels || current_user_level < redact_level} onChange={this.onPowerLevelsChanged}/>
id="mx_RoomSettings_user_level" disabled={!can_change_levels || current_user_level < default_user_level}
onChange={this.onPowerLevelsChanged}/>
</div> </div>
</div>
<h3>User levels</h3>
<div className="mx_RoomSettings_user_levels mx_RoomSettings_settings">
{Object.keys(user_levels).map(function(user, i) {
return (
<div key={user}>
<label htmlFor={"mx_RoomSettings_user_"+i}>{user}</label>
<input type="text" defaultValue={user_levels[user]} size="3" id={"mx_RoomSettings_user_"+i} disabled/>
</div>
);
})}
</div>
<h3>Event levels</h3>
<div className="mx_RoomSettings_event_lvels mx_RoomSettings_settings">
{Object.keys(events_levels).map(function(event_type, i) { {Object.keys(events_levels).map(function(event_type, i) {
return ( return (
<div key={event_type}> <div className="mx_RoomSettings_powerLevel" key={event_type}>
<label htmlFor={"mx_RoomSettings_event_"+i}>{event_type}</label> <span className="mx_RoomSettings_powerLevelKey">To send events of type <code>{ event_type }</code>, you must be a </span>
<input type="text" defaultValue={events_levels[event_type]} size="3" id={"mx_RoomSettings_event_"+i} disabled/> <PowerSelector value={ events_levels[event_type] } disabled={true} onChange={self.onPowerLevelsChanged}/>
</div> </div>
); );
})} })}
{ unfederatable_section }
</div> </div>
<h3>Banned users</h3> <h3>Users</h3>
<div className="mx_RoomSettings_banned"> <div className="mx_RoomSettings_userLevels mx_RoomSettings_settings">
{banned.map(function(member, i) { <div>
return ( Your role in this room is currently <b><PowerSelector room={ this.props.room } value={current_user_level} disabled={true}/></b>.
<div key={i}> </div>
{member.userId}
</div> { user_levels_section }
);
})}
</div> </div>
{change_avatar}
{ banned_users_section }
<h3>Advanced</h3>
<div className="mx_RoomSettings_settings">
This room's internal ID is <code>{ this.props.room.roomId }</code>
</div>
</div> </div>
); );
} }

View file

@ -123,7 +123,7 @@ module.exports = React.createClass({
return connectDragSource(connectDropTarget( return connectDragSource(connectDropTarget(
<div className={classes} onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}> <div className={classes} onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className="mx_RoomTile_avatar"> <div className="mx_RoomTile_avatar">
<RoomAvatar room={this.props.room} width="24" height="24" /> <RoomAvatar room={this.props.room} width={24} height={24} />
{ badge } { badge }
</div> </div>
{ label } { label }

View file

@ -18,6 +18,7 @@ limitations under the License.
var React = require('react'); var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var CommandEntry = require("../../../TabCompleteEntries").CommandEntry;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'TabCompleteBar', displayName: 'TabCompleteBar',
@ -31,8 +32,9 @@ module.exports = React.createClass({
<div className="mx_TabCompleteBar"> <div className="mx_TabCompleteBar">
{this.props.entries.map(function(entry, i) { {this.props.entries.map(function(entry, i) {
return ( return (
<div key={entry.getKey() || i + ""} className="mx_TabCompleteBar_item" <div key={entry.getKey() || i + ""}
onClick={entry.onClick.bind(entry)} > className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") }
onClick={entry.onClick.bind(entry)} >
{entry.getImageJsx()} {entry.getImageJsx()}
<span className="mx_TabCompleteBar_text"> <span className="mx_TabCompleteBar_text">
{entry.getText()} {entry.getText()}

View file

@ -25,6 +25,8 @@ module.exports = React.createClass({
room: React.PropTypes.object, room: React.PropTypes.object,
// if false, you need to call changeAvatar.onFileSelected yourself. // if false, you need to call changeAvatar.onFileSelected yourself.
showUploadSection: React.PropTypes.bool, showUploadSection: React.PropTypes.bool,
width: React.PropTypes.number,
height: React.PropTypes.number,
className: React.PropTypes.string className: React.PropTypes.string
}, },
@ -37,7 +39,9 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
showUploadSection: true, showUploadSection: true,
className: "mx_Dialog_content" // FIXME - shouldn't be this by default className: "",
width: 80,
height: 80,
}; };
}, },
@ -111,13 +115,15 @@ module.exports = React.createClass({
// Having just set an avatar we just display that since it will take a little // Having just set an avatar we just display that since it will take a little
// time to propagate through to the RoomAvatar. // time to propagate through to the RoomAvatar.
if (this.props.room && !this.avatarSet) { if (this.props.room && !this.avatarSet) {
avatarImg = <RoomAvatar room={this.props.room} width='320' height='240' resizeMethod='scale' />; avatarImg = <RoomAvatar room={this.props.room} width={ this.props.width } height={ this.props.height } resizeMethod='crop' />;
} else { } else {
var style = { var style = {
maxWidth: 320, width: this.props.width,
maxHeight: 240, height: this.props.height,
objectFit: 'cover',
}; };
avatarImg = <img src={this.state.avatarUrl} style={style} />; // FIXME: surely we should be using MemberAvatar or UserAvatar or something here...
avatarImg = <img className="mx_RoomAvatar" src={this.state.avatarUrl} style={style} />;
} }
var uploadSection; var uploadSection;

View file

@ -99,7 +99,9 @@ module.exports = React.createClass({
var EditableText = sdk.getComponent('elements.EditableText'); var EditableText = sdk.getComponent('elements.EditableText');
return ( return (
<EditableText ref="displayname_edit" initialValue={this.state.displayName} <EditableText ref="displayname_edit" initialValue={this.state.displayName}
label="Click to set display name." className="mx_EditableText"
placeholderClassName="mx_EditableText_placeholder"
placeholder="No display name"
onValueChanged={this.onValueChanged} /> onValueChanged={this.onValueChanged} />
); );
} }

View file

@ -36,7 +36,7 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// a callback which is called when the video within the callview // a callback which is called when the video within the callview
// due to a change in video metadata // due to a change in video metadata
onResize: React.PropTypes.function, onResize: React.PropTypes.func,
}, },
componentDidMount: function() { componentDidMount: function() {

View file

@ -24,7 +24,7 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// a callback which is called when the video element is resized // a callback which is called when the video element is resized
// due to a change in video metadata // due to a change in video metadata
onResize: React.PropTypes.function, onResize: React.PropTypes.func,
}, },
componentDidMount() { componentDidMount() {