Merge remote-tracking branch 'origin/develop' into unread_sync

This commit is contained in:
David Baker 2016-01-21 10:36:29 +00:00
commit 13e70e6956
61 changed files with 4360 additions and 890 deletions

View file

@ -35,7 +35,8 @@
"react-dom": "^0.14.2",
"react-gemini-scrollbar": "^2.0.1",
"sanitize-html": "^1.11.1",
"velocity-animate": "^1.2.3"
"velocity-animate": "^1.2.3",
"velocity-ui-pack": "^1.2.2"
},
"//deps": "The loader packages are here because webpack in a project that depends on us needs them in this package's node_modules folder",
"//depsbuglink": "https://github.com/webpack/webpack/issues/1472",

76
src/AddThreepid.js Normal file
View file

@ -0,0 +1,76 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var MatrixClientPeg = require("./MatrixClientPeg");
/**
* Allows a user to add a third party identifier to their Home Server and,
* optionally, the identity servers.
*
* This involves getting an email token from the identity server to "prove" that
* the client owns the given email address, which is then passed to the
* add threepid API on the homeserver.
*/
class AddThreepid {
constructor() {
this.clientSecret = MatrixClientPeg.get().generateClientSecret();
}
/**
* Attempt to add an email threepid. This will trigger a side-effect of
* sending an email to the provided email address.
* @param {string} emailAddress The email address to add
* @param {boolean} bind If True, bind this email to this mxid on the Identity Server
* @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked().
*/
addEmailAddress(emailAddress, bind) {
this.bind = bind;
return MatrixClientPeg.get().requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid;
return res;
}, function(err) {
if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`;
}
throw err;
});
}
/**
* Checks if the email link has been clicked by attempting to add the threepid
* @return {Promise} Resolves if the password was reset. Rejects with an object
* with a "message" property which contains a human-readable message detailing why
* the reset failed, e.g. "There is no mapped matrix user ID for the given email address".
*/
checkEmailLinkClicked() {
var identityServerDomain = MatrixClientPeg.get().idBaseUrl.split("://")[1];
return MatrixClientPeg.get().addThreePid({
sid: this.sessionId,
client_secret: this.clientSecret,
id_server: identityServerDomain
}, this.bind).catch(function(err) {
if (err.httpStatus === 401) {
err.message = "Failed to verify email address: make sure you clicked the link in the email";
}
else if (err.httpStatus) {
err.message += ` (Status ${err.httpStatus})`;
}
throw err;
});
}
}
module.exports = AddThreepid;

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
'use strict';
var ContentRepo = require("matrix-js-sdk").ContentRepo;
var MatrixClientPeg = require('./MatrixClientPeg');
module.exports = {
@ -37,6 +37,17 @@ module.exports = {
return url;
},
avatarUrlForUser: function(user, width, height, resizeMethod) {
var url = ContentRepo.getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
width, height, resizeMethod
);
if (!url || url.length === 0) {
return null;
}
return url;
},
defaultAvatarUrlForString: function(s) {
var images = [ '76cfa6', '50e2c2', 'f4c371' ];
var total = 0;

107
src/Entities.js Normal file
View file

@ -0,0 +1,107 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var sdk = require('./index');
/*
* Converts various data models to Entity objects.
*
* Entity objects provide an interface for UI components to use to display
* members in a data-agnostic way. This means they don't need to care if the
* underlying data model is a RoomMember, User or 3PID data structure, it just
* cares about rendering.
*/
class Entity {
constructor(model) {
this.model = model;
}
getJsx() {
return null;
}
matches(queryString) {
return false;
}
}
class MemberEntity extends Entity {
getJsx() {
var MemberTile = sdk.getComponent("rooms.MemberTile");
return (
<MemberTile key={this.model.userId} member={this.model} />
);
}
matches(queryString) {
return this.model.name.toLowerCase().indexOf(queryString.toLowerCase()) === 0;
}
}
class UserEntity extends Entity {
constructor(model, showInviteButton, inviteFn) {
super(model);
this.showInviteButton = Boolean(showInviteButton);
this.inviteFn = inviteFn;
}
onClick() {
if (this.inviteFn) {
this.inviteFn(this.model.userId);
}
}
getJsx() {
var UserTile = sdk.getComponent("rooms.UserTile");
return (
<UserTile key={this.model.userId} user={this.model}
showInviteButton={this.showInviteButton} onClick={this.onClick.bind(this)} />
);
}
matches(queryString) {
var name = this.model.displayName || this.model.userId;
return name.toLowerCase().indexOf(queryString.toLowerCase()) === 0;
}
}
module.exports = {
/**
* @param {RoomMember[]} members
* @return {Entity[]}
*/
fromRoomMembers: function(members) {
return members.map(function(m) {
return new MemberEntity(m);
});
},
/**
* @param {User[]} users
* @param {boolean} showInviteButton
* @param {Function} inviteFn Called with the user ID.
* @return {Entity[]}
*/
fromUsers: function(users, showInviteButton, inviteFn) {
return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn);
})
}
};

51
src/GuestAccess.js Normal file
View file

@ -0,0 +1,51 @@
/*
Copyright 2015 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const IS_GUEST_KEY = "matrix-is-guest";
class GuestAccess {
constructor(localStorage) {
this.localStorage = localStorage;
try {
this._isGuest = localStorage.getItem(IS_GUEST_KEY) === "true";
}
catch (e) {} // don't care
}
setPeekedRoom(roomId) {
// we purposefully do not persist this to local storage as peeking is
// entirely transient.
this._peekedRoomId = roomId;
}
getPeekedRoom() {
return this._peekedRoomId;
}
isGuest() {
return this._isGuest;
}
markAsGuest(isGuest) {
try {
this.localStorage.setItem(IS_GUEST_KEY, JSON.stringify(isGuest));
} catch (e) {} // ignore. If they don't do LS, they'll just get a new account.
this._isGuest = isGuest;
this._peekedRoomId = null;
}
}
module.exports = GuestAccess;

View file

@ -18,6 +18,7 @@ limitations under the License.
// A thing that holds your Matrix Client
var Matrix = require("matrix-js-sdk");
var GuestAccess = require("./GuestAccess");
var matrixClient = null;
@ -33,7 +34,7 @@ function deviceId() {
return id;
}
function createClient(hs_url, is_url, user_id, access_token) {
function createClient(hs_url, is_url, user_id, access_token, guestAccess) {
var opts = {
baseUrl: hs_url,
idBaseUrl: is_url,
@ -47,6 +48,15 @@ function createClient(hs_url, is_url, user_id, access_token) {
}
matrixClient = Matrix.createClient(opts);
if (guestAccess) {
console.log("Guest: %s", guestAccess.isGuest());
matrixClient.setGuest(guestAccess.isGuest());
var peekedRoomId = guestAccess.getPeekedRoom();
if (peekedRoomId) {
console.log("Peeking in room %s", peekedRoomId);
matrixClient.peekInRoom(peekedRoomId);
}
}
}
if (localStorage) {
@ -54,12 +64,18 @@ if (localStorage) {
var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org';
var access_token = localStorage.getItem("mx_access_token");
var user_id = localStorage.getItem("mx_user_id");
var guestAccess = new GuestAccess(localStorage);
if (access_token && user_id && hs_url) {
createClient(hs_url, is_url, user_id, access_token);
createClient(hs_url, is_url, user_id, access_token, guestAccess);
}
}
class MatrixClient {
constructor(guestAccess) {
this.guestAccess = guestAccess;
}
get() {
return matrixClient;
}
@ -97,7 +113,7 @@ class MatrixClient {
}
}
replaceUsingAccessToken(hs_url, is_url, user_id, access_token) {
replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) {
if (localStorage) {
try {
localStorage.clear();
@ -105,7 +121,8 @@ class MatrixClient {
console.warn("Error using local storage");
}
}
createClient(hs_url, is_url, user_id, access_token);
this.guestAccess.markAsGuest(Boolean(isGuest));
createClient(hs_url, is_url, user_id, access_token, this.guestAccess);
if (localStorage) {
try {
localStorage.setItem("mx_hs_url", hs_url);
@ -122,6 +139,6 @@ class MatrixClient {
}
if (!global.mxMatrixClient) {
global.mxMatrixClient = new MatrixClient();
global.mxMatrixClient = new MatrixClient(new GuestAccess(localStorage));
}
module.exports = global.mxMatrixClient;

92
src/PasswordReset.js Normal file
View file

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

View file

@ -73,6 +73,11 @@ class Presence {
}
var old_state = this.state;
this.state = newState;
if (MatrixClientPeg.get().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
var self = this;
MatrixClientPeg.get().setPresence(this.state).done(function() {
console.log("Presence: %s", newState);

View file

@ -69,6 +69,10 @@ class Register extends Signup {
this.params.idSid = idSid;
}
setGuestAccessToken(token) {
this.guestAccessToken = token;
}
getStep() {
return this._step;
}
@ -126,7 +130,8 @@ class Register extends Signup {
}
return MatrixClientPeg.get().register(
this.username, this.password, this.params.sessionId, authDict, bindEmail
this.username, this.password, this.params.sessionId, authDict, bindEmail,
this.guestAccessToken
).then(function(result) {
self.credentials = result;
self.setStep("COMPLETE");
@ -147,6 +152,8 @@ class Register extends Signup {
} else {
if (error.errcode === 'M_USER_IN_USE') {
throw new Error("Username in use");
} else if (error.errcode == 'M_INVALID_USERNAME') {
throw new Error("User names may only contain alphanumeric characters, underscores or dots!");
} else if (error.httpStatus == 401) {
throw new Error("Authorisation failed!");
} else if (error.httpStatus >= 400 && error.httpStatus < 500) {

View file

@ -18,6 +18,32 @@ var MatrixClientPeg = require("./MatrixClientPeg");
var MatrixTools = require("./MatrixTools");
var dis = require("./dispatcher");
var encryption = require("./encryption");
var Tinter = require("./Tinter");
class Command {
constructor(name, paramArgs, runFn) {
this.name = name;
this.paramArgs = paramArgs;
this.runFn = runFn;
}
getCommand() {
return "/" + this.name;
}
getCommandWithArgs() {
return this.getCommand() + " " + this.paramArgs;
}
run(roomId, args) {
return this.runFn.bind(this)(roomId, args);
}
getUsage() {
return "Usage: " + this.getCommandWithArgs()
}
}
var reject = function(msg) {
return {
@ -33,16 +59,37 @@ var success = function(promise) {
var commands = {
// Change your nickname
nick: function(room_id, args) {
nick: new Command("nick", "<display_name>", function(room_id, args) {
if (args) {
return success(
MatrixClientPeg.get().setDisplayName(args)
);
}
return reject("Usage: /nick <display_name>");
},
return reject(this.getUsage());
}),
encrypt: function(room_id, args) {
// Changes the colorscheme of your current room
tint: new Command("tint", "<color1> [<color2>]", function(room_id, args) {
if (args) {
var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/);
if (matches) {
Tinter.tint(matches[1], matches[4]);
var colorScheme = {}
colorScheme.primary_color = matches[1];
if (matches[4]) {
colorScheme.secondary_color = matches[4];
}
return success(
MatrixClientPeg.get().setRoomAccountData(
room_id, "org.matrix.room.color_scheme", colorScheme
)
);
}
}
return reject(this.getUsage());
}),
encrypt: new Command("encrypt", "<on|off>", function(room_id, args) {
if (args == "on") {
var client = MatrixClientPeg.get();
var members = client.getRoom(room_id).currentState.members;
@ -58,21 +105,21 @@ var commands = {
);
}
return reject("Usage: encrypt <on/off>");
},
return reject(this.getUsage());
}),
// Change the room topic
topic: function(room_id, args) {
topic: new Command("topic", "<topic>", function(room_id, args) {
if (args) {
return success(
MatrixClientPeg.get().setRoomTopic(room_id, args)
);
}
return reject("Usage: /topic <topic>");
},
return reject(this.getUsage());
}),
// Invite a user
invite: function(room_id, args) {
invite: new Command("invite", "<userId>", function(room_id, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
@ -81,11 +128,11 @@ var commands = {
);
}
}
return reject("Usage: /invite <userId>");
},
return reject(this.getUsage());
}),
// Join a room
join: function(room_id, args) {
join: new Command("join", "<room_alias>", function(room_id, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
@ -94,8 +141,7 @@ var commands = {
return reject("Usage: /join #alias:domain");
}
if (!room_alias.match(/:/)) {
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, '');
room_alias += ':' + domain;
room_alias += ':' + MatrixClientPeg.get().getDomain();
}
// Try to find a room with this alias
@ -128,21 +174,20 @@ var commands = {
);
}
}
return reject("Usage: /join <room_alias>");
},
return reject(this.getUsage());
}),
part: function(room_id, args) {
part: new Command("part", "[#alias:domain]", function(room_id, args) {
var targetRoomId;
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
var room_alias = matches[1];
if (room_alias[0] !== '#') {
return reject("Usage: /part [#alias:domain]");
return reject(this.getUsage());
}
if (!room_alias.match(/:/)) {
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, '');
room_alias += ':' + domain;
room_alias += ':' + MatrixClientPeg.get().getDomain();
}
// Try to find a room with this alias
@ -175,10 +220,10 @@ var commands = {
dis.dispatch({action: 'view_next_room'});
})
);
},
}),
// Kick a user from the room with an optional reason
kick: function(room_id, args) {
kick: new Command("kick", "<userId> [<reason>]", function(room_id, args) {
if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
@ -187,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: function(room_id, args) {
ban: new Command("ban", "<userId> [<reason>]", function(room_id, args) {
if (args) {
var matches = args.match(/^(\S+?)( +(.*))?$/);
if (matches) {
@ -200,11 +245,11 @@ var commands = {
);
}
}
return reject("Usage: /ban <userId> [<reason>]");
},
return reject(this.getUsage());
}),
// Unban a user from the room
unban: function(room_id, args) {
unban: new Command("unban", "<userId>", function(room_id, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
@ -214,11 +259,11 @@ var commands = {
);
}
}
return reject("Usage: /unban <userId>");
},
return reject(this.getUsage());
}),
// 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) {
var matches = args.match(/^(\S+?)( +(\d+))?$/);
var powerLevel = 50; // default power level for op
@ -243,11 +288,11 @@ var commands = {
}
}
}
return reject("Usage: /op <userId> [<power level>]");
},
return reject(this.getUsage());
}),
// Reset the power level of a user
deop: function(room_id, args) {
deop: new Command("deop", "<userId>", function(room_id, args) {
if (args) {
var matches = args.match(/^(\S+)$/);
if (matches) {
@ -266,12 +311,14 @@ var commands = {
);
}
}
return reject("Usage: /deop <userId>");
}
return reject(this.getUsage());
})
};
// helpful aliases
commands.j = commands.join;
var aliases = {
j: "join"
}
module.exports = {
/**
@ -291,13 +338,26 @@ module.exports = {
var cmd = bits[1].substring(1).toLowerCase();
var args = bits[3];
if (cmd === "me") return null;
if (aliases[cmd]) {
cmd = aliases[cmd];
}
if (commands[cmd]) {
return commands[cmd](roomId, args);
return commands[cmd].run(roomId, args);
}
else {
return reject("Unrecognised command: " + input);
}
}
return null; // not a command
},
getCommandList: function() {
// Return all the commands plus /me which isn't handled like normal commands
var cmds = Object.keys(commands).sort().map(function(cmdKey) {
return commands[cmdKey];
})
cmds.push(new Command("me", "<action>", function(){}));
return cmds;
}
};

View file

@ -32,8 +32,6 @@ const MATCH_REGEX = /(^|\s)(\S+)$/;
class TabComplete {
constructor(opts) {
opts.startingWordSuffix = opts.startingWordSuffix || "";
opts.wordSuffix = opts.wordSuffix || "";
opts.allowLooping = opts.allowLooping || false;
opts.autoEnterTabComplete = opts.autoEnterTabComplete || false;
opts.onClickCompletes = opts.onClickCompletes || false;
@ -58,7 +56,7 @@ class TabComplete {
// assign onClick listeners for each entry to complete the text
this.list.forEach((l) => {
l.onClick = () => {
this.completeTo(l.getText());
this.completeTo(l);
}
});
}
@ -93,10 +91,12 @@ class TabComplete {
/**
* Do an auto-complete with the given word. This terminates the tab-complete.
* @param {string} someVal
* @param {Entry} entry The tab-complete entry to complete to.
*/
completeTo(someVal) {
this.textArea.value = this._replaceWith(someVal, true);
completeTo(entry) {
this.textArea.value = this._replaceWith(
entry.getFillText(), true, entry.getSuffix(this.isFirstWord)
);
this.stopTabCompleting();
// keep focus on the text area
this.textArea.focus();
@ -222,8 +222,9 @@ class TabComplete {
if (!this.inPassiveMode) {
// set textarea to this new value
this.textArea.value = this._replaceWith(
this.matchedList[this.currentIndex].text,
this.currentIndex !== 0 // don't suffix the original text!
this.matchedList[this.currentIndex].getFillText(),
this.currentIndex !== 0, // don't suffix the original text!
this.matchedList[this.currentIndex].getSuffix(this.isFirstWord)
);
}
@ -243,7 +244,7 @@ class TabComplete {
}
}
_replaceWith(newVal, includeSuffix) {
_replaceWith(newVal, includeSuffix, suffix) {
// The regex to replace the input matches a character of whitespace AND
// the partial word. If we just use string.replace() with the regex it will
// replace the partial word AND the character of whitespace. We want to
@ -258,13 +259,12 @@ class TabComplete {
boundaryChar = "";
}
var replacementText = (
boundaryChar + newVal + (
includeSuffix ?
(this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix) :
""
)
);
suffix = suffix || "";
if (!includeSuffix) {
suffix = "";
}
var replacementText = boundaryChar + newVal + suffix;
return this.originalText.replace(MATCH_REGEX, function() {
return replacementText; // function form to avoid `$` special-casing
});

View file

@ -28,6 +28,14 @@ class Entry {
return this.text;
}
/**
* @return {string} The text to insert into the input box. Most of the time
* this is the same as getText().
*/
getFillText() {
return this.text;
}
/**
* @return {ReactClass} Raw JSX
*/
@ -42,6 +50,14 @@ class Entry {
return null;
}
/**
* @return {?string} The suffix to append to the tab-complete, or null to
* not do this.
*/
getSuffix(isFirstWord) {
return null;
}
/**
* Called when this entry is clicked.
*/
@ -50,6 +66,31 @@ class Entry {
}
}
class CommandEntry extends Entry {
constructor(cmd, cmdWithArgs) {
super(cmdWithArgs);
this.cmd = cmd;
}
getFillText() {
return this.cmd;
}
getKey() {
return this.getFillText();
}
getSuffix(isFirstWord) {
return " "; // force a space after the command.
}
}
CommandEntry.fromCommands = function(commandArray) {
return commandArray.map(function(cmd) {
return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs());
});
}
class MemberEntry extends Entry {
constructor(member) {
super(member.name || member.userId);
@ -66,6 +107,10 @@ class MemberEntry extends Entry {
getKey() {
return this.member.userId;
}
getSuffix(isFirstWord) {
return isFirstWord ? ": " : " ";
}
}
MemberEntry.fromMemberList = function(members) {
@ -99,3 +144,4 @@ MemberEntry.fromMemberList = function(members) {
module.exports.Entry = Entry;
module.exports.MemberEntry = MemberEntry;
module.exports.CommandEntry = CommandEntry;

View file

@ -66,7 +66,7 @@ function textForMemberEvent(ev) {
function textForTopicEvent(ev) {
var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return senderDisplayName + ' changed the topic to, "' + ev.getContent().topic + '"';
return senderDisplayName + ' changed the topic to "' + ev.getContent().topic + '"';
};
function textForRoomNameEvent(ev) {

211
src/Tinter.js Normal file
View file

@ -0,0 +1,211 @@
/*
Copyright 2015 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var dis = require("./dispatcher");
// FIXME: these vars should be bundled up and attached to
// module.exports otherwise this will break when included by both
// react-sdk and apps layered on top.
// The colour keys to be replaced as referred to in SVGs
var keyRgb = [
"rgb(118, 207, 166)", // Vector Green
"rgb(234, 245, 240)", // Vector Light Green
"rgba(118, 207, 166, 0.2)", // BottomLeftMenu overlay (20% Vector Green)
];
// Some algebra workings for calculating the tint % of Vector Green & Light Green
// x * 118 + (1 - x) * 255 = 234
// x * 118 + 255 - 255 * x = 234
// x * 118 - x * 255 = 234 - 255
// (255 - 118) x = 255 - 234
// x = (255 - 234) / (255 - 118) = 0.16
// The colour keys to be replaced as referred to in SVGs
var keyHex = [
"#76CFA6", // Vector Green
"#EAF5F0", // Vector Light Green
"#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green)
];
// cache of our replacement colours
// defaults to our keys.
var colors = [
keyHex[0],
keyHex[1],
keyHex[2],
];
var cssFixups = [
// {
// style: a style object that should be fixed up taken from a stylesheet
// attr: name of the attribute to be clobbered, e.g. 'color'
// index: ordinal of primary, secondary or tertiary
// }
];
// CSS attributes to be fixed up
var cssAttrs = [
"color",
"backgroundColor",
"borderColor",
"borderTopColor",
"borderBottomColor",
];
var svgAttrs = [
"fill",
"stroke",
];
var cached = false;
function calcCssFixups() {
for (var i = 0; i < document.styleSheets.length; i++) {
var ss = document.styleSheets[i];
for (var j = 0; j < ss.cssRules.length; j++) {
var rule = ss.cssRules[j];
if (!rule.style) continue;
for (var k = 0; k < cssAttrs.length; k++) {
var attr = cssAttrs[k];
for (var l = 0; l < keyRgb.length; l++) {
if (rule.style[attr] === keyRgb[l]) {
cssFixups.push({
style: rule.style,
attr: attr,
index: l,
});
}
}
}
}
}
}
function applyCssFixups() {
for (var i = 0; i < cssFixups.length; i++) {
var cssFixup = cssFixups[i];
cssFixup.style[cssFixup.attr] = colors[cssFixup.index];
}
}
function hexToRgb(color) {
if (color[0] === '#') color = color.slice(1);
if (color.length === 3) {
color = color[0] + color[0] +
color[1] + color[1] +
color[2] + color[2];
}
var val = parseInt(color, 16);
var r = (val >> 16) & 255;
var g = (val >> 8) & 255;
var b = val & 255;
return [r, g, b];
}
function rgbToHex(rgb) {
var val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
return '#' + (0x1000000 + val).toString(16).slice(1)
}
module.exports = {
tint: function(primaryColor, secondaryColor, tertiaryColor) {
if (!cached) {
calcCssFixups();
cached = true;
}
if (!primaryColor) {
primaryColor = "#76CFA6"; // Vector green
secondaryColor = "#EAF5F0"; // Vector light green
}
if (!secondaryColor) {
var x = 0.16; // average weighting factor calculated from vector green & light green
var rgb = hexToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255;
rgb[2] = x * rgb[2] + (1 - x) * 255;
secondaryColor = rgbToHex(rgb);
}
if (!tertiaryColor) {
var x = 0.19;
var rgb1 = hexToRgb(primaryColor);
var rgb2 = hexToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
rgb1[1] = x * rgb1[1] + (1 - x) * rgb2[1];
rgb1[2] = x * rgb1[2] + (1 - x) * rgb2[2];
tertiaryColor = rgbToHex(rgb1);
}
if (colors[0] === primaryColor &&
colors[1] === secondaryColor &&
colors[2] === tertiaryColor)
{
return;
}
colors = [primaryColor, secondaryColor, tertiaryColor];
// go through manually fixing up the stylesheets.
applyCssFixups();
// tell all the SVGs to go fix themselves up
dis.dispatch({ action: 'tint_update' });
},
// XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now.
calcSvgFixups: function(svgs) {
// go through manually fixing up SVG colours.
// we could do this by stylesheets, but keeping the stylesheets
// updated would be a PITA, so just brute-force search for the
// key colour; cache the element and apply.
var fixups = [];
for (var i = 0; i < svgs.length; i++) {
var svgDoc = svgs[i].contentDocument;
if (!svgDoc) continue;
var tags = svgDoc.getElementsByTagName("*");
for (var j = 0; j < tags.length; j++) {
var tag = tags[j];
for (var k = 0; k < svgAttrs.length; k++) {
var attr = svgAttrs[k];
for (var l = 0; l < keyHex.length; l++) {
if (tag.getAttribute(attr) && tag.getAttribute(attr).toUpperCase() === keyHex[l]) {
fixups.push({
node: tag,
attr: attr,
index: l,
});
}
}
}
}
}
return fixups;
},
applySvgFixups: function(fixups) {
for (var i = 0; i < fixups.length; i++) {
var svgFixup = fixups[i];
svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]);
}
},
};

View file

@ -16,7 +16,8 @@ limitations under the License.
var dis = require("./dispatcher");
var MIN_DISPATCH_INTERVAL = 1 * 1000;
var MIN_DISPATCH_INTERVAL_MS = 500;
var CURRENTLY_ACTIVE_THRESHOLD_MS = 500;
/**
* This class watches for user activity (moving the mouse or pressing a key)
@ -31,8 +32,14 @@ class UserActivity {
start() {
document.onmousemove = this._onUserActivity.bind(this);
document.onkeypress = this._onUserActivity.bind(this);
// can't use document.scroll here because that's only the document
// itself being scrolled. Need to use addEventListener's useCapture.
// also this needs to be the wheel event, not scroll, as scroll is
// fired when the view scrolls down for a new message.
window.addEventListener('wheel', this._onUserActivity.bind(this), true);
this.lastActivityAtTs = new Date().getTime();
this.lastDispatchAtTs = 0;
this.activityEndTimer = undefined;
}
/**
@ -41,10 +48,19 @@ class UserActivity {
stop() {
document.onmousemove = undefined;
document.onkeypress = undefined;
window.removeEventListener('wheel', this._onUserActivity.bind(this), true);
}
/**
* Return true if there has been user activity very recently
* (ie. within a few seconds)
*/
userCurrentlyActive() {
return this.lastActivityAtTs > new Date().getTime() - CURRENTLY_ACTIVE_THRESHOLD_MS;
}
_onUserActivity(event) {
if (event.screenX) {
if (event.screenX && event.type == "mousemove") {
if (event.screenX === this.lastScreenX &&
event.screenY === this.lastScreenY)
{
@ -55,12 +71,32 @@ class UserActivity {
this.lastScreenY = event.screenY;
}
this.lastActivityAtTs = (new Date).getTime();
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL) {
this.lastActivityAtTs = new Date().getTime();
if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL_MS) {
this.lastDispatchAtTs = this.lastActivityAtTs;
dis.dispatch({
action: 'user_activity'
});
if (!this.activityEndTimer) {
this.activityEndTimer = setTimeout(
this._onActivityEndTimer.bind(this), MIN_DISPATCH_INTERVAL_MS
);
}
}
}
_onActivityEndTimer() {
var now = new Date().getTime();
var targetTime = this.lastActivityAtTs + MIN_DISPATCH_INTERVAL_MS;
if (now >= targetTime) {
dis.dispatch({
action: 'user_activity_end'
});
this.activityEndTimer = undefined;
} else {
this.activityEndTimer = setTimeout(
this._onActivityEndTimer.bind(this), targetTime - now
);
}
}
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
'use strict';
var q = require("q");
var MatrixClientPeg = require("./MatrixClientPeg");
var Notifier = require("./Notifier");
@ -35,6 +35,11 @@ module.exports = {
},
loadThreePids: function() {
if (MatrixClientPeg.get().isGuest()) {
return q({
threepids: []
}); // guests can't poke 3pid endpoint
}
return MatrixClientPeg.get().getThreePids();
},

View file

@ -36,7 +36,7 @@ module.exports = React.createClass({
var old = oldChildren[c.key];
var oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
if (oldNode.style.left != c.props.style.left) {
if (oldNode && oldNode.style.left != c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() {
// special case visibility because it's nonsensical to animate an invisible element
// so we always hidden->visible pre-transition and visible->hidden after
@ -73,6 +73,7 @@ module.exports = React.createClass({
collectNode: function(k, node) {
if (
node &&
this.nodes[k] === undefined &&
node.props.startStyle &&
Object.keys(node.props.startStyle).length

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.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings');
module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword');
module.exports.components['structures.login.Login'] = require('./components/structures/login/Login');
module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration');
module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration');
module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar');
module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar');
module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar');
module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton');
@ -39,8 +44,11 @@ module.exports.components['views.create_room.RoomAlias'] = require('./components
module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog');
module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt');
module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog');
module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog');
module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText');
module.exports.components['views.elements.PowerSelector'] = require('./components/views/elements/PowerSelector');
module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar');
module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg');
module.exports.components['views.elements.UserSelector'] = require('./components/views/elements/UserSelector');
module.exports.components['views.login.CaptchaForm'] = require('./components/views/login/CaptchaForm');
module.exports.components['views.login.CasLogin'] = require('./components/views/login/CasLogin');
@ -57,17 +65,22 @@ module.exports.components['views.messages.MVideoBody'] = require('./components/v
module.exports.components['views.messages.TextualBody'] = require('./components/views/messages/TextualBody');
module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent');
module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody');
module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile');
module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile');
module.exports.components['views.rooms.MemberInfo'] = require('./components/views/rooms/MemberInfo');
module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList');
module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile');
module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer');
module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel');
module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader');
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList');
module.exports.components['views.rooms.RoomPreviewBar'] = require('./components/views/rooms/RoomPreviewBar');
module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings');
module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile');
module.exports.components['views.rooms.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList');
module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile');
module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar');
module.exports.components['views.rooms.UserTile'] = require('./components/views/rooms/UserTile');
module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword');

View file

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

View file

@ -31,6 +31,7 @@ var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration");
var Modal = require("../../Modal");
var Tinter = require("../../Tinter");
var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix");
@ -43,6 +44,7 @@ module.exports = React.createClass({
ConferenceHandler: React.PropTypes.any,
onNewScreen: React.PropTypes.func,
registrationUrl: React.PropTypes.string,
enableGuest: React.PropTypes.bool,
startingQueryParams: React.PropTypes.object
},
@ -63,7 +65,8 @@ module.exports = React.createClass({
collapse_lhs: false,
collapse_rhs: false,
ready: false,
width: 10000
width: 10000,
autoPeek: true, // by default, we peek into rooms when we try to join them
};
if (s.logged_in) {
if (MatrixClientPeg.get().getRooms().length) {
@ -88,8 +91,21 @@ module.exports = React.createClass({
},
componentDidMount: function() {
this._autoRegisterAsGuest = false;
if (this.props.enableGuest) {
if (!this.props.config || !this.props.config.default_hs_url) {
console.error("Cannot enable guest access: No supplied config prop for HS/IS URLs");
}
else {
this._autoRegisterAsGuest = true;
}
}
this.dispatcherRef = dis.register(this.onAction);
if (this.state.logged_in) {
// Don't auto-register as a guest. This applies if you refresh the page on a
// logged in client THEN hit the Sign Out button.
this._autoRegisterAsGuest = false;
this.startMatrixClient();
}
this.focusComposer = false;
@ -98,8 +114,11 @@ module.exports = React.createClass({
this.scrollStateMap = {};
document.addEventListener("keydown", this.onKeyDown);
window.addEventListener("focus", this.onFocus);
if (this.state.logged_in) {
this.notifyNewScreen('');
} else if (this._autoRegisterAsGuest) {
this._registerAsGuest();
} else {
this.notifyNewScreen('login');
}
@ -131,6 +150,34 @@ module.exports = React.createClass({
}
},
_registerAsGuest: function() {
var self = this;
var config = this.props.config;
console.log("Doing guest login on %s", config.default_hs_url);
MatrixClientPeg.replaceUsingUrls(
config.default_hs_url, config.default_is_url
);
MatrixClientPeg.get().registerGuest().done(function(creds) {
console.log("Registered as guest: %s", creds.user_id);
self._setAutoRegisterAsGuest(false);
self.onLoggedIn({
userId: creds.user_id,
accessToken: creds.access_token,
homeserverUrl: config.default_hs_url,
identityServerUrl: config.default_is_url,
guest: true
});
}, function(err) {
console.error(err.data);
self._setAutoRegisterAsGuest(false);
});
},
_setAutoRegisterAsGuest: function(shouldAutoRegister) {
this._autoRegisterAsGuest = shouldAutoRegister;
this.forceUpdate();
},
onAction: function(payload) {
var roomIndexDelta = 1;
@ -185,6 +232,21 @@ module.exports = React.createClass({
screen: 'post_registration'
});
break;
case 'start_upgrade_registration':
this.replaceState({
screen: "register",
upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(),
guestAccessToken: MatrixClientPeg.get().getAccessToken()
});
this.notifyNewScreen('register');
break;
case 'start_password_recovery':
if (this.state.logged_in) return;
this.replaceState({
screen: 'forgot_password'
});
this.notifyNewScreen('forgot_password');
break;
case 'token_login':
if (this.state.logged_in) return;
@ -248,7 +310,10 @@ module.exports = React.createClass({
});
break;
case 'view_room':
this._viewRoom(payload.room_id);
// by default we autoPeek rooms, unless we were called explicitly with
// autoPeek=false by something like RoomDirectory who has already peeked
this.setState({ autoPeek : payload.auto_peek === false ? false : true });
this._viewRoom(payload.room_id, payload.show_settings);
break;
case 'view_prev_room':
roomIndexDelta = -1;
@ -301,8 +366,29 @@ module.exports = React.createClass({
this.notifyNewScreen('settings');
break;
case 'view_create_room':
this._setPage(this.PageTypes.CreateRoom);
this.notifyNewScreen('new');
//this._setPage(this.PageTypes.CreateRoom);
//this.notifyNewScreen('new');
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader);
MatrixClientPeg.get().createRoom({
preset: "private_chat"
}).done(function(res) {
modal.close();
dis.dispatch({
action: 'view_room',
room_id: res.room_id,
// show_settings: true,
});
}, function(err) {
modal.close();
Modal.createDialog(ErrorDialog, {
title: "Failed to create room",
description: err.toString()
});
});
break;
case 'view_room_directory':
this._setPage(this.PageTypes.RoomDirectory);
@ -343,7 +429,7 @@ module.exports = React.createClass({
});
},
_viewRoom: function(roomId) {
_viewRoom: function(roomId, showSettings) {
// before we switch room, record the scroll state of the current room
this._updateScrollMap();
@ -363,7 +449,16 @@ module.exports = React.createClass({
if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
if (theAlias) presentedId = theAlias;
var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
var color_scheme = {};
if (color_scheme_event) {
color_scheme = color_scheme_event.getContent();
// XXX: we should validate the event
}
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}
this.notifyNewScreen('room/'+presentedId);
newState.ready = true;
}
@ -372,6 +467,9 @@ module.exports = React.createClass({
var scrollState = this.scrollStateMap[roomId];
this.refs.roomView.restoreScrollState(scrollState);
}
if (this.refs.roomView && showSettings) {
this.refs.roomView.showSettings(true);
}
},
// update scrollStateMap according to the current scroll state of the
@ -387,10 +485,11 @@ module.exports = React.createClass({
},
onLoggedIn: function(credentials) {
console.log("onLoggedIn => %s", credentials.userId);
credentials.guest = Boolean(credentials.guest);
console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest);
MatrixClientPeg.replaceUsingAccessToken(
credentials.homeserverUrl, credentials.identityServerUrl,
credentials.userId, credentials.accessToken
credentials.userId, credentials.accessToken, credentials.guest
);
this.setState({
screen: undefined,
@ -457,7 +556,9 @@ module.exports = React.createClass({
UserActivity.start();
Presence.start();
cli.startClient({
pendingEventOrdering: "end"
pendingEventOrdering: "end",
// deliberately huge limit for now to avoid hitting gappy /sync's until gappy /sync performance improves
initialSyncLimit: 250,
});
},
@ -511,6 +612,11 @@ module.exports = React.createClass({
action: 'token_login',
params: params
});
} else if (screen == 'forgot_password') {
dis.dispatch({
action: 'start_password_recovery',
params: params
});
} else if (screen == 'new') {
dis.dispatch({
action: 'view_create_room',
@ -566,6 +672,8 @@ module.exports = React.createClass({
onUserClick: function(event, userId) {
event.preventDefault();
/*
var MemberInfo = sdk.getComponent('rooms.MemberInfo');
var member = new Matrix.RoomMember(null, userId);
ContextualMenu.createMenu(MemberInfo, {
@ -573,6 +681,14 @@ module.exports = React.createClass({
right: window.innerWidth - event.pageX,
top: event.pageY
});
*/
var member = new Matrix.RoomMember(null, userId);
if (!member) { return; }
dis.dispatch({
action: 'view_user',
member: member,
});
},
onLogoutClick: function(event) {
@ -620,10 +736,22 @@ module.exports = React.createClass({
this.showScreen("login");
},
onForgotPasswordClick: function() {
this.showScreen("forgot_password");
},
onRegistered: function(credentials) {
this.onLoggedIn(credentials);
// do post-registration stuff
this.showScreen("post_registration");
// This now goes straight to user settings
// We use _setPage since if we wait for
// showScreen to do the dispatch loop,
// the showScreen dispatch will race with the
// sdk sync finishing and we'll probably see
// the page type still unset when the MatrixClient
// is started and show the Room Directory instead.
//this.showScreen("view_user_settings");
this._setPage(this.PageTypes.UserSettings);
},
onFinishPostRegistration: function() {
@ -639,7 +767,9 @@ module.exports = React.createClass({
var rooms = MatrixClientPeg.get().getRooms();
for (var i = 0; i < rooms.length; ++i) {
if (rooms[i].getUnreadNotificationCount()) {
if (rooms[i].hasMembershipState(MatrixClientPeg.get().credentials.userId, 'invite')) {
++notifCount;
} else if (rooms[i].getUnreadNotificationCount()) {
notifCount += rooms[i].getUnreadNotificationCount();
}
}
@ -671,6 +801,7 @@ module.exports = React.createClass({
var CreateRoom = sdk.getComponent('structures.CreateRoom');
var RoomDirectory = sdk.getComponent('structures.RoomDirectory');
var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
// needs to be before normal PageTypes as you are logged in technically
if (this.state.screen == 'post_registration') {
@ -689,6 +820,7 @@ module.exports = React.createClass({
<RoomView
ref="roomView"
roomId={this.state.currentRoom}
autoPeek={this.state.autoPeek}
key={this.state.currentRoom}
ConferenceHandler={this.props.ConferenceHandler} />
);
@ -734,12 +866,20 @@ module.exports = React.createClass({
</div>
);
}
} else if (this.state.logged_in) {
} else if (this.state.logged_in || (!this.state.logged_in && this._autoRegisterAsGuest)) {
var Spinner = sdk.getComponent('elements.Spinner');
var logoutLink;
if (this.state.logged_in) {
logoutLink = (
<a href="#" className="mx_MatrixChat_splashButtons" onClick={ this.onLogoutClick }>
Logout
</a>
);
}
return (
<div className="mx_MatrixChat_splash">
<Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={ this.onLogoutClick }>Logout</a>
{logoutLink}
</div>
);
} else if (this.state.screen == 'register') {
@ -749,19 +889,30 @@ module.exports = React.createClass({
sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid}
email={this.props.startingQueryParams.email}
username={this.state.upgradeUsername}
disableUsernameChanges={Boolean(this.state.upgradeUsername)}
guestAccessToken={this.state.guestAccessToken}
hsUrl={this.props.config.default_hs_url}
isUrl={this.props.config.default_is_url}
registrationUrl={this.props.registrationUrl}
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick} />
);
} else if (this.state.screen == 'forgot_password') {
return (
<ForgotPassword
homeserverUrl={this.props.config.default_hs_url}
identityServerUrl={this.props.config.default_is_url}
onComplete={this.onLoginClick} />
);
} else {
return (
<Login
onLoggedIn={this.onLoggedIn}
onRegisterClick={this.onRegisterClick}
homeserverUrl={this.props.config.default_hs_url}
identityServerUrl={this.props.config.default_is_url} />
identityServerUrl={this.props.config.default_is_url}
onForgotPasswordClick={this.onForgotPasswordClick} />
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -112,6 +112,14 @@ module.exports = React.createClass({
this.checkFillState();
},
componentWillUnmount: function() {
// set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results.
//
// (We could use isMounted(), but facebook have deprecated that.)
this.unmounted = true;
},
onScroll: function(ev) {
var sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
@ -158,6 +166,10 @@ module.exports = React.createClass({
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
if (this.unmounted) {
return;
}
var sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the
@ -346,6 +358,12 @@ module.exports = React.createClass({
* message panel.
*/
_getScrollNode: function() {
if (this.unmounted) {
// this shouldn't happen, but when it does, turn the NPE into
// something more meaningful.
throw new Error("ScrollPanel._getScrollNode called when unmounted");
}
var panel = ReactDOM.findDOMNode(this.refs.geminiPanel);
// If the gemini scrollbar is doing its thing, this will be a div within

View file

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

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var ReactDOM = require('react-dom');
var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg");
var Modal = require('../../Modal');
@ -21,6 +22,9 @@ var dis = require("../../dispatcher");
var q = require('q');
var version = require('../../../package.json').version;
var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
module.exports = React.createClass({
displayName: 'UserSettings',
@ -41,6 +45,7 @@ module.exports = React.createClass({
threePids: [],
clientVersion: version,
phase: "UserSettings.LOADING", // LOADING, DISPLAY
email_add_pending: false,
};
},
@ -83,6 +88,12 @@ module.exports = React.createClass({
}
},
onAvatarPickerClick: function(ev) {
if (this.refs.file_label) {
this.refs.file_label.click();
}
},
onAvatarSelected: function(ev) {
var self = this;
var changeAvatar = this.refs.changeAvatar;
@ -135,6 +146,12 @@ module.exports = React.createClass({
});
},
onUpgradeClicked: function() {
dis.dispatch({
action: "start_upgrade_registration"
});
},
onLogoutPromptCancel: function() {
this.logoutModal.closeDialog();
},
@ -143,10 +160,81 @@ module.exports = React.createClass({
UserSettingsStore.setEnableNotifications(event.target.checked);
},
onAddThreepidClicked: function(value, shouldSubmit) {
if (!shouldSubmit) return;
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var email_address = this.refs.add_threepid_input.value;
if (!Email.looksValid(email_address)) {
Modal.createDialog(ErrorDialog, {
title: "Invalid Email Address",
description: "This doesn't appear to be a valid email address",
});
return;
}
this.add_threepid = new AddThreepid();
// we always bind emails when registering, so let's do the
// same here.
this.add_threepid.addEmailAddress(email_address, true).done(() => {
Modal.createDialog(QuestionDialog, {
title: "Verification Pending",
description: "Please check your email and click on the link it contains. Once this is done, click continue.",
button: 'Continue',
onFinished: this.onEmailDialogFinished,
});
}, (err) => {
Modal.createDialog(ErrorDialog, {
title: "Unable to add email address",
description: err.toString()
});
});
ReactDOM.findDOMNode(this.refs.add_threepid_input).blur();
this.setState({email_add_pending: true});
},
onEmailDialogFinished: function(ok) {
if (ok) {
this.verifyEmailAddress();
} else {
this.setState({email_add_pending: false});
}
},
verifyEmailAddress: function() {
this.add_threepid.checkEmailLinkClicked().done(() => {
this.add_threepid = undefined;
this.setState({
phase: "UserSettings.LOADING",
});
this._refreshFromServer();
this.setState({email_add_pending: false});
}, (err) => {
if (err.errcode == 'M_THREEPID_AUTH_FAILED') {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var message = "Unable to verify email address. "
message += "Please check your email and click on the link it contains. Once this is done, click continue."
Modal.createDialog(QuestionDialog, {
title: "Verification Pending",
description: message,
button: 'Continue',
onFinished: this.onEmailDialogFinished,
});
} else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Unable to verify email address",
description: err.toString(),
});
}
});
},
render: function() {
var self = this;
var Loader = sdk.getComponent("elements.Spinner");
switch (this.state.phase) {
case "UserSettings.LOADING":
var Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
);
@ -160,14 +248,76 @@ module.exports = React.createClass({
var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName");
var ChangePassword = sdk.getComponent("views.settings.ChangePassword");
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var Notifications = sdk.getComponent("settings.Notifications");
var EditableText = sdk.getComponent('elements.EditableText');
var avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
);
var threepidsSection = this.state.threepids.map(function(val, pidIndex) {
var id = "email-" + val.address;
return (
<div className="mx_UserSettings_profileTableRow" key={pidIndex}>
<div className="mx_UserSettings_profileLabelCell">
<label htmlFor={id}>Email</label>
</div>
<div className="mx_UserSettings_profileInputCell">
<input key={val.address} id={id} value={val.address} disabled />
</div>
</div>
);
});
var addThreepidSection;
if (this.state.email_add_pending) {
addThreepidSection = <Loader />;
} else {
addThreepidSection = (
<div className="mx_UserSettings_profileTableRow" key="new">
<div className="mx_UserSettings_profileLabelCell">
</div>
<EditableText
ref="add_threepid_input"
className="mx_UserSettings_profileInputCell mx_UserSettings_editable"
placeholderClassName="mx_RoomSettings_threepidPlaceholder"
placeholder={ "Add email address" }
blurToCancel={ false }
onValueChanged={ this.onAddThreepidClicked } />
<div className="mx_RoomSettings_addThreepid">
<img src="img/plus.svg" width="14" height="14" alt="Add" onClick={ this.onAddThreepidClicked }/>
</div>
</div>
);
}
threepidsSection.push(addThreepidSection);
var accountJsx;
if (MatrixClientPeg.get().isGuest()) {
accountJsx = (
<div className="mx_UserSettings_button" onClick={this.onUpgradeClicked}>
Create an account
</div>
);
}
else {
accountJsx = (
<ChangePassword
className="mx_UserSettings_accountTable"
rowClassName="mx_UserSettings_profileTableRow"
rowLabelClassName="mx_UserSettings_profileLabelCell"
rowInputClassName="mx_UserSettings_profileInputCell"
buttonClassName="mx_UserSettings_button mx_UserSettings_changePasswordButton"
onError={this.onPasswordChangeError}
onFinished={this.onPasswordChanged} />
);
}
return (
<div className="mx_UserSettings">
<RoomHeader simpleHeader="Settings" />
<GeminiScrollbar className="mx_UserSettings_body" autoshow={true}>
<h2>Profile</h2>
<div className="mx_UserSettings_section">
@ -180,30 +330,19 @@ module.exports = React.createClass({
<ChangeDisplayName />
</div>
</div>
{this.state.threepids.map(function(val, pidIndex) {
var id = "email-" + val.address;
return (
<div className="mx_UserSettings_profileTableRow" key={pidIndex}>
<div className="mx_UserSettings_profileLabelCell">
<label htmlFor={id}>Email</label>
</div>
<div className="mx_UserSettings_profileInputCell">
<input key={val.address} id={id} value={val.address} disabled />
</div>
</div>
);
})}
{threepidsSection}
</div>
<div className="mx_UserSettings_avatarPicker">
<ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/>
<div onClick={ this.onAvatarPickerClick }>
<ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/>
</div>
<div className="mx_UserSettings_avatarPicker_edit">
<label htmlFor="avatarInput">
<img src="img/upload.svg"
<label htmlFor="avatarInput" ref="file_label">
<img src="img/camera.svg"
alt="Upload avatar" title="Upload avatar"
width="19" height="24" />
width="17" height="15" />
</label>
<input id="avatarInput" type="file" onChange={this.onAvatarSelected}/>
</div>
@ -213,41 +352,18 @@ module.exports = React.createClass({
<h2>Account</h2>
<div className="mx_UserSettings_section">
<ChangePassword
className="mx_UserSettings_accountTable"
rowClassName="mx_UserSettings_profileTableRow"
rowLabelClassName="mx_UserSettings_profileLabelCell"
rowInputClassName="mx_UserSettings_profileInputCell"
buttonClassName="mx_UserSettings_button"
onError={this.onPasswordChangeError}
onFinished={this.onPasswordChanged} />
</div>
<div className="mx_UserSettings_logout">
<div className="mx_UserSettings_button" onClick={this.onLogoutClicked}>
<div className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
Log out
</div>
{accountJsx}
</div>
<h2>Notifications</h2>
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_notifTable">
<div className="mx_UserSettings_notifTableRow">
<div className="mx_UserSettings_notifInputCell">
<input id="enableNotifications"
ref="enableNotifications"
type="checkbox"
checked={ UserSettingsStore.getEnableNotifications() }
onChange={ this.onEnableNotificationsChange } />
</div>
<div className="mx_UserSettings_notifLabelCell">
<label htmlFor="enableNotifications">
Enable desktop notifications
</label>
</div>
</div>
</div>
<Notifications/>
</div>
<h2>Advanced</h2>
@ -260,6 +376,8 @@ module.exports = React.createClass({
Version {this.state.clientVersion}
</div>
</div>
</GeminiScrollbar>
</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,
identityServerUrl: React.PropTypes.string,
// login shouldn't know or care how registration is done.
onRegisterClick: React.PropTypes.func.isRequired
onRegisterClick: React.PropTypes.func.isRequired,
// login shouldn't care how password recovery is done.
onForgotPasswordClick: React.PropTypes.func
},
getDefaultProps: function() {
@ -138,7 +140,9 @@ module.exports = React.createClass({displayName: 'Login',
switch (step) {
case 'm.login.password':
return (
<PasswordLogin onSubmit={this.onPasswordLogin} />
<PasswordLogin
onSubmit={this.onPasswordLogin}
onForgotPasswordClick={this.props.onForgotPasswordClick} />
);
case 'm.login.cas':
return (

View file

@ -19,7 +19,6 @@ limitations under the License.
var React = require('react');
var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var dis = require('../../../dispatcher');
var Signup = require("../../../Signup");
var ServerConfig = require("../../views/login/ServerConfig");
@ -40,6 +39,9 @@ module.exports = React.createClass({
hsUrl: React.PropTypes.string,
isUrl: React.PropTypes.string,
email: React.PropTypes.string,
username: React.PropTypes.string,
guestAccessToken: React.PropTypes.string,
disableUsernameChanges: React.PropTypes.bool,
// registration shouldn't know or care how login is done.
onLoginClick: React.PropTypes.func.isRequired
},
@ -63,6 +65,7 @@ module.exports = React.createClass({
this.registerLogic.setSessionId(this.props.sessionId);
this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
this.registerLogic.recheckState();
},
@ -156,6 +159,15 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`;
break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = "This doesn't look like a valid email address";
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = "You need to enter a user name";
break;
default:
console.error("Unknown error code: %s", errCode);
errMsg = "An unknown error occurred.";
@ -186,7 +198,9 @@ module.exports = React.createClass({
registerStep = (
<RegistrationForm
showEmail={true}
defaultUsername={this.props.username}
defaultEmail={this.props.email}
disableUsernameChanges={this.props.disableUsernameChanges}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} />

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 Avatar = require('../../../Avatar');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require("../../../index");
module.exports = React.createClass({
displayName: 'MemberAvatar',
@ -27,7 +27,7 @@ module.exports = React.createClass({
member: React.PropTypes.object.isRequired,
width: React.PropTypes.number,
height: React.PropTypes.number,
resizeMethod: React.PropTypes.string,
resizeMethod: React.PropTypes.string
},
getDefaultProps: function() {
@ -38,75 +38,30 @@ module.exports = React.createClass({
}
},
componentWillReceiveProps: function(nextProps) {
this.refreshUrl();
},
defaultAvatarUrl: function(member, width, height, resizeMethod) {
return Avatar.defaultAvatarUrlForString(member.userId);
},
onError: function(ev) {
// don't tightloop if the browser can't load a data url
if (ev.target.src == this.defaultAvatarUrl(this.props.member)) {
return;
}
this.setState({
imageUrl: this.defaultAvatarUrl(this.props.member)
});
},
_computeUrl: function() {
return Avatar.avatarUrlForMember(this.props.member,
this.props.width,
this.props.height,
this.props.resizeMethod);
},
refreshUrl: function() {
var newUrl = this._computeUrl();
if (newUrl != this.currentUrl) {
this.currentUrl = newUrl;
this.setState({imageUrl: newUrl});
}
},
getInitialState: function() {
return {
imageUrl: this._computeUrl()
};
return this._getState(this.props);
},
componentWillReceiveProps: function(nextProps) {
this.setState(this._getState(nextProps));
},
///////////////
_getState: function(props) {
return {
name: props.member.name,
title: props.member.userId,
imageUrl: Avatar.avatarUrlForMember(props.member,
props.width,
props.height,
props.resizeMethod)
}
},
render: function() {
// XXX: recalculates default avatar url constantly
if (this.state.imageUrl === this.defaultAvatarUrl(this.props.member)) {
var initial;
if (this.props.member.name[0])
initial = this.props.member.name[0].toUpperCase();
if (initial === '@' && this.props.member.name[1])
initial = this.props.member.name[1].toUpperCase();
return (
<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>
);
}
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
return (
<img className="mx_MemberAvatar mx_MemberAvatar_image" src={this.state.imageUrl}
onError={this.onError}
width={this.props.width} height={this.props.height}
title={this.props.member.name}
{...this.props}
/>
<BaseAvatar {...this.props} name={this.state.name} title={this.state.title}
idName={this.props.member.userId} url={this.state.imageUrl} />
);
}
});

View file

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

View file

@ -48,7 +48,7 @@ module.exports = React.createClass({
render: function() {
return (
<div className="mx_ErrorDialog">
<div className="mx_ErrorDialogTitle">
<div className="mx_Dialog_title">
{this.props.title}
</div>
<div className="mx_Dialog_content">

View file

@ -46,7 +46,7 @@ module.exports = React.createClass({
render: function() {
return (
<div className="mx_QuestionDialog">
<div className="mx_QuestionDialogTitle">
<div className="mx_Dialog_title">
{this.props.title}
</div>
<div className="mx_Dialog_content">

View file

@ -0,0 +1,94 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require("react");
module.exports = React.createClass({
displayName: 'TextInputDialog',
propTypes: {
title: React.PropTypes.string,
description: React.PropTypes.string,
value: React.PropTypes.string,
button: React.PropTypes.string,
focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired
},
getDefaultProps: function() {
return {
title: "",
value: "",
description: "",
button: "OK",
focus: true
};
},
componentDidMount: function() {
if (this.props.focus) {
// Set the cursor at the end of the text input
this.refs.textinput.value = this.props.value;
}
},
onOk: function() {
this.props.onFinished(true, this.refs.textinput.value);
},
onCancel: function() {
this.props.onFinished(false);
},
onKeyDown: function(e) {
if (e.keyCode === 27) { // escape
e.stopPropagation();
e.preventDefault();
this.props.onFinished(false);
}
else if (e.keyCode === 13) { // enter
e.stopPropagation();
e.preventDefault();
this.props.onFinished(true, this.refs.textinput.value);
}
},
render: function() {
return (
<div className="mx_TextInputDialog">
<div className="mx_Dialog_title">
{this.props.title}
</div>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> {this.props.description} </label>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" onKeyDown={this.onKeyDown}/>
</div>
</div>
<div className="mx_Dialog_buttons">
<button onClick={this.onOk}>
{this.props.button}
</button>
<button onClick={this.onCancel}>
Cancel
</button>
</div>
</div>
);
}
});

View file

@ -18,13 +18,22 @@ limitations under the License.
var React = require('react');
const KEY_TAB = 9;
const KEY_SHIFT = 16;
const KEY_WINDOWS = 91;
module.exports = React.createClass({
displayName: 'EditableText',
propTypes: {
onValueChanged: React.PropTypes.func,
initialValue: React.PropTypes.string,
label: React.PropTypes.string,
placeHolder: React.PropTypes.string,
placeholder: React.PropTypes.string,
className: React.PropTypes.string,
labelClassName: React.PropTypes.string,
placeholderClassName: React.PropTypes.string,
blurToCancel: React.PropTypes.bool,
editable: React.PropTypes.bool,
},
Phases: {
@ -36,38 +45,62 @@ module.exports = React.createClass({
return {
onValueChanged: function() {},
initialValue: '',
label: 'Click to set',
label: '',
placeholder: '',
editable: true,
};
},
getInitialState: function() {
return {
value: this.props.initialValue,
phase: this.Phases.Display,
}
},
componentWillReceiveProps: function(nextProps) {
this.setState({
value: nextProps.initialValue
});
if (nextProps.initialValue !== this.props.initialValue) {
this.value = nextProps.initialValue;
if (this.refs.editable_div) {
this.showPlaceholder(!this.value);
}
}
},
componentWillMount: function() {
// we track value as an JS object field rather than in React state
// as React doesn't play nice with contentEditable.
this.value = '';
this.placeholder = false;
},
componentDidMount: function() {
this.value = this.props.initialValue;
if (this.refs.editable_div) {
this.showPlaceholder(!this.value);
}
},
showPlaceholder: function(show) {
if (show) {
this.refs.editable_div.textContent = this.props.placeholder;
this.refs.editable_div.setAttribute("class", this.props.className + " " + this.props.placeholderClassName);
this.placeholder = true;
this.value = '';
}
else {
this.refs.editable_div.textContent = this.value;
this.refs.editable_div.setAttribute("class", this.props.className);
this.placeholder = false;
}
},
getValue: function() {
return this.state.value;
return this.value;
},
setValue: function(val, shouldSubmit, suppressListener) {
var self = this;
this.setState({
value: val,
phase: this.Phases.Display,
}, function() {
if (!suppressListener) {
self.onValueChanged(shouldSubmit);
}
});
setValue: function(value) {
this.value = value;
this.showPlaceholder(!this.value);
},
edit: function() {
@ -80,65 +113,106 @@ module.exports = React.createClass({
this.setState({
phase: this.Phases.Display,
});
this.value = this.props.initialValue;
this.showPlaceholder(!this.value);
this.onValueChanged(false);
},
onValueChanged: function(shouldSubmit) {
this.props.onValueChanged(this.state.value, shouldSubmit);
this.props.onValueChanged(this.value, shouldSubmit);
},
onKeyDown: function(ev) {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (this.placeholder) {
this.showPlaceholder(false);
}
if (ev.key == "Enter") {
ev.stopPropagation();
ev.preventDefault();
}
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
},
onKeyUp: function(ev) {
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (!ev.target.textContent) {
this.showPlaceholder(true);
}
else if (!this.placeholder) {
this.value = ev.target.textContent;
}
if (ev.key == "Enter") {
this.onFinish(ev);
} else if (ev.key == "Escape") {
this.cancelEdit();
}
// console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
},
onClickDiv: function() {
onClickDiv: function(ev) {
if (!this.props.editable) return;
this.setState({
phase: this.Phases.Edit,
})
},
onFocus: function(ev) {
ev.target.setSelectionRange(0, ev.target.value.length);
},
//ev.target.setSelectionRange(0, ev.target.textContent.length);
onFinish: function(ev) {
if (ev.target.value) {
this.setValue(ev.target.value, ev.key === "Enter");
} else {
this.cancelEdit();
var node = ev.target.childNodes[0];
if (node) {
var range = document.createRange();
range.setStart(node, 0);
range.setEnd(node, node.length);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
},
onBlur: function() {
this.cancelEdit();
onFinish: function(ev) {
var self = this;
var submit = (ev.key === "Enter");
this.setState({
phase: this.Phases.Display,
}, function() {
self.onValueChanged(submit);
});
},
onBlur: function(ev) {
var sel = window.getSelection();
sel.removeAllRanges();
if (this.props.blurToCancel)
this.cancelEdit();
else
this.onFinish(ev);
this.showPlaceholder(!this.value);
},
render: function() {
var editable_el;
if (this.state.phase == this.Phases.Display) {
if (this.state.value) {
editable_el = <div ref="display_div" onClick={this.onClickDiv}>{this.state.value}</div>;
} else {
editable_el = <div ref="display_div" onClick={this.onClickDiv}>{this.props.label}</div>;
}
} else if (this.state.phase == this.Phases.Edit) {
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>
);
if (!this.props.editable || (this.state.phase == this.Phases.Display && (this.props.label || this.props.labelClassName) && !this.value)) {
// show the label
editable_el = <div className={this.props.className + " " + this.props.labelClassName} onClick={this.onClickDiv}>{ this.props.label || this.props.initialValue }</div>;
} else {
// 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}
onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onBlur}></div>;
}
return (
<div className="mx_EditableText">
{editable_el}
</div>
);
return editable_el;
}
});

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

@ -0,0 +1,69 @@
/*
Copyright 2015 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var ReactDOM = require("react-dom");
var dis = require("../../../dispatcher");
var Tinter = require("../../../Tinter");
module.exports = React.createClass({
displayName: 'TintableSvg',
propTypes: {
src: React.PropTypes.string.isRequired,
width: React.PropTypes.string.isRequired,
height: React.PropTypes.string.isRequired,
className: React.PropTypes.string,
},
componentWillMount: function() {
this.fixups = [];
this.dispatcherRef = dis.register(this.onAction);
},
componentDidMount: function() {
// we can't use onLoad on object due to https://github.com/facebook/react/pull/5781
// so handle it with pure DOM instead
ReactDOM.findDOMNode(this).addEventListener('load', this.onLoad);
},
componentWillUnmount: function() {
ReactDOM.findDOMNode(this).removeEventListener('load', this.onLoad);
dis.unregister(this.dispatcherRef);
},
onAction: function(payload) {
if (payload.action !== 'tint_update') return;
Tinter.applySvgFixups(this.fixups);
},
onLoad: function(event) {
this.fixups = Tinter.calcSvgFixups([event.target]);
Tinter.applySvgFixups(this.fixups);
},
render: function() {
return (
<object className={ "mx_TintableSvg " + this.props.className }
type="image/svg+xml"
data={ this.props.src }
width={ this.props.width }
height={ this.props.height }/>
);
}
});

View file

@ -22,7 +22,7 @@ module.exports = React.createClass({
render: function() {
return (
<div className="mx_ErrorDialog">
<div className="mx_ErrorDialogTitle">
<div className="mx_Dialog_title">
Custom Server Options
</div>
<div className="mx_Dialog_content">

View file

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

View file

@ -17,7 +17,15 @@ limitations under the License.
'use strict';
var React = require('react');
var Velocity = require('velocity-animate');
require('velocity-ui-pack');
var sdk = require('../../../index');
var Email = require('../../../email');
var FIELD_EMAIL = 'field_email';
var FIELD_USERNAME = 'field_username';
var FIELD_PASSWORD = 'field_password';
var FIELD_PASSWORD_CONFIRM = 'field_password_confirm';
/**
* A pure UI component which displays a registration form.
@ -30,6 +38,7 @@ module.exports = React.createClass({
defaultUsername: React.PropTypes.string,
showEmail: React.PropTypes.bool,
minPasswordLength: React.PropTypes.number,
disableUsernameChanges: React.PropTypes.bool,
onError: React.PropTypes.func,
onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise
},
@ -49,52 +58,151 @@ module.exports = React.createClass({
email: this.props.defaultEmail,
username: this.props.defaultUsername,
password: null,
passwordConfirm: null
passwordConfirm: null,
fieldValid: {}
};
},
onSubmit: function(ev) {
ev.preventDefault();
var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim()
// validate everything, in reverse order so
// the error that ends up being displayed
// is the one from the first invalid field.
// It's not super ideal that this just calls
// onError once for each invalid field.
this.validateField(FIELD_PASSWORD_CONFIRM);
this.validateField(FIELD_PASSWORD);
this.validateField(FIELD_USERNAME);
this.validateField(FIELD_EMAIL);
var errCode;
if (!pwd1 || !pwd2) {
errCode = "RegistrationForm.ERR_PASSWORD_MISSING";
}
else if (pwd1 !== pwd2) {
errCode = "RegistrationForm.ERR_PASSWORD_MISMATCH";
}
else if (pwd1.length < this.props.minPasswordLength) {
errCode = "RegistrationForm.ERR_PASSWORD_LENGTH";
}
if (errCode) {
this.props.onError(errCode);
return;
}
var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim(),
password: pwd1,
email: this.refs.email.value.trim()
});
if (promise) {
ev.target.disabled = true;
promise.finally(function() {
ev.target.disabled = false;
if (this.allFieldsValid()) {
var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim(),
password: this.refs.password.value.trim(),
email: this.refs.email.value.trim()
});
if (promise) {
ev.target.disabled = true;
promise.finally(function() {
ev.target.disabled = false;
});
}
}
},
/**
* Returns true if all fields were valid last time
* they were validated.
*/
allFieldsValid: function() {
var keys = Object.keys(this.state.fieldValid);
for (var i = 0; i < keys.length; ++i) {
if (this.state.fieldValid[keys[i]] == false) {
return false;
}
}
return true;
},
validateField: function(field_id) {
var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim()
switch (field_id) {
case FIELD_EMAIL:
this.markFieldValid(
field_id,
this.refs.email.value == '' || Email.looksValid(this.refs.email.value),
"RegistrationForm.ERR_EMAIL_INVALID"
);
break;
case FIELD_USERNAME:
// XXX: SPEC-1
if (encodeURIComponent(this.refs.username.value) != this.refs.username.value) {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_USERNAME_INVALID"
);
} else if (this.refs.username.value == '') {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_USERNAME_BLANK"
);
} else {
this.markFieldValid(field_id, true);
}
break;
case FIELD_PASSWORD:
if (pwd1 == '') {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_PASSWORD_MISSING"
);
} else if (pwd1.length < this.props.minPasswordLength) {
this.markFieldValid(
field_id,
false,
"RegistrationForm.ERR_PASSWORD_LENGTH"
);
} else {
this.markFieldValid(field_id, true);
}
break;
case FIELD_PASSWORD_CONFIRM:
this.markFieldValid(
field_id, pwd1 == pwd2,
"RegistrationForm.ERR_PASSWORD_MISMATCH"
);
break;
}
},
markFieldValid: function(field_id, val, error_code) {
var fieldValid = this.state.fieldValid;
fieldValid[field_id] = val;
this.setState({fieldValid: fieldValid});
if (!val) {
Velocity(this.fieldElementById(field_id), "callout.shake", 300);
this.props.onError(error_code);
}
},
fieldElementById(field_id) {
switch (field_id) {
case FIELD_EMAIL:
return this.refs.email;
case FIELD_USERNAME:
return this.refs.username;
case FIELD_PASSWORD:
return this.refs.password;
case FIELD_PASSWORD_CONFIRM:
return this.refs.passwordConfirm;
}
},
_styleField: function(field_id, baseStyle) {
var style = baseStyle || {};
if (this.state.fieldValid[field_id] === false) {
style['borderColor'] = 'red';
}
return style;
},
render: function() {
var self = this;
var emailSection, registerButton;
if (this.props.showEmail) {
emailSection = (
<input className="mx_Login_field" type="text" ref="email"
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) {
@ -109,13 +217,20 @@ module.exports = React.createClass({
{emailSection}
<br />
<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} />
<br />
<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} />
<br />
<input className="mx_Login_field" type="password" ref="passwordConfirm"
placeholder="Confirm password"
style={this._styleField(FIELD_PASSWORD_CONFIRM)}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
defaultValue={this.state.passwordConfirm} />
<br />
{registerButton}

View file

@ -19,6 +19,8 @@ limitations under the License.
var React = require('react');
var filesize = require('filesize');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
module.exports = React.createClass({
displayName: 'MFileBody',
@ -52,12 +54,14 @@ module.exports = React.createClass({
var httpUrl = cli.mxcUrlToHttp(content.url);
var text = this.presentableTextForFile(content);
var TintableSvg = sdk.getComponent("elements.TintableSvg");
if (httpUrl) {
return (
<span className="mx_MFileBody">
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank">
<img src="img/download.png" width="10" height="12"/>
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {text}
</a>
</div>

View file

@ -22,6 +22,7 @@ var filesize = require('filesize');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
module.exports = React.createClass({
displayName: 'MImageBody',
@ -97,6 +98,7 @@ module.exports = React.createClass({
},
render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var content = this.props.mxEvent.getContent();
var cli = MatrixClientPeg.get();
@ -118,7 +120,7 @@ module.exports = React.createClass({
</a>
<div className="mx_MImageBody_download">
<a href={cli.mxcUrlToHttp(content.url)} target="_blank">
<img src="img/download.png" width="10" height="12"/>
<TintableSvg src="img/download.svg" width="12" height="14"/>
Download {content.body} ({ content.info && content.info.size ? filesize(content.info.size) : "Unknown size" })
</a>
</div>

View file

@ -36,6 +36,9 @@ module.exports = React.createClass({
},
componentDidUpdate: function() {
// XXX: why don't we linkify here?
// XXX: why do we bother doing this on update at all, given events are immutable?
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
},

View file

@ -0,0 +1,139 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var PRESENCE_CLASS = {
"offline": "mx_EntityTile_offline",
"online": "mx_EntityTile_online",
"unavailable": "mx_EntityTile_unavailable"
};
module.exports = React.createClass({
displayName: 'EntityTile',
propTypes: {
name: React.PropTypes.string,
title: React.PropTypes.string,
avatarJsx: React.PropTypes.any, // <BaseAvatar />
presenceState: React.PropTypes.string,
presenceActiveAgo: React.PropTypes.number,
showInviteButton: React.PropTypes.bool,
shouldComponentUpdate: React.PropTypes.func,
onClick: React.PropTypes.func
},
getDefaultProps: function() {
return {
shouldComponentUpdate: function(nextProps, nextState) { return false; },
onClick: function() {},
presenceState: "offline",
presenceActiveAgo: -1,
showInviteButton: false,
};
},
getInitialState: function() {
return {
hover: false
};
},
shouldComponentUpdate: function(nextProps, nextState) {
if (this.state.hover !== nextState.hover) return true;
return this.props.shouldComponentUpdate(nextProps, nextState);
},
mouseEnter: function(e) {
this.setState({ 'hover': true });
},
mouseLeave: function(e) {
this.setState({ 'hover': false });
},
render: function() {
var presenceClass = PRESENCE_CLASS[this.props.presenceState] || "mx_EntityTile_offline";
var mainClassName = "mx_EntityTile ";
mainClassName += presenceClass;
if (this.state.hover) {
mainClassName += " mx_EntityTile_hover";
}
var nameEl;
if (this.state.hover) {
var PresenceLabel = sdk.getComponent("rooms.PresenceLabel");
nameEl = (
<div className="mx_EntityTile_details">
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12"/>
<div className="mx_EntityTile_name_hover">{ this.props.name }</div>
<PresenceLabel activeAgo={this.props.presenceActiveAgo}
presenceState={this.props.presenceState} />
</div>
);
}
else {
nameEl = (
<div className="mx_EntityTile_name">
{ this.props.name }
</div>
);
}
var inviteButton;
if (this.props.showInviteButton) {
inviteButton = (
<div className="mx_EntityTile_invite">
<img src="img/plus.svg" width="16" height="16" />
</div>
);
}
var power;
var powerLevel = this.props.powerLevel;
if (powerLevel >= 50 && powerLevel < 99) {
power = <img src="img/mod.svg" className="mx_EntityTile_power" width="16" height="17" alt="Mod"/>;
}
if (powerLevel >= 99) {
power = <img src="img/admin.svg" className="mx_EntityTile_power" width="16" height="17" alt="Admin"/>;
}
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var av = this.props.avatarJsx || <BaseAvatar name={this.props.name} width={36} height={36} />;
return (
<div className={mainClassName} title={ this.props.title }
onClick={ this.props.onClick } onMouseEnter={ this.mouseEnter }
onMouseLeave={ this.mouseLeave }>
<div className="mx_EntityTile_avatar">
{ av }
{ power }
</div>
{ nameEl }
{ inviteButton }
</div>
);
}
});

View file

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

View file

@ -15,12 +15,20 @@ limitations under the License.
*/
var React = require('react');
var classNames = require('classnames');
var Matrix = require("matrix-js-sdk");
var q = require('q');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal");
var Entities = require("../../../Entities");
var sdk = require('../../../index');
var GeminiScrollbar = require('react-gemini-scrollbar');
var INITIAL_LOAD_NUM_MEMBERS = 50;
var SHARE_HISTORY_WARNING = "Newly invited users will see the history of this room. "+
"If you'd prefer invited users not to see messages that were sent before they joined, "+
"turn off, 'Share message history with new users' in the settings for this room.";
var shown_invite_warning_this_session = false;
module.exports = React.createClass({
displayName: 'MemberList',
@ -63,8 +71,13 @@ module.exports = React.createClass({
self.setState({
members: self.roomMembers()
});
// lazy load to prevent it blocking the first render
self._loadUserList();
}, 50);
setTimeout
// Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
@ -88,6 +101,21 @@ module.exports = React.createClass({
/*componentWillReceiveProps: function(newProps) {
},*/
_loadUserList: function() {
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (!room) {
return; // we'll do it later
}
// Load the complete user list for inviting new users
// TODO: Keep this list bleeding-edge up-to-date. Practically speaking,
// it will do for now not being updated as random new users join different
// rooms as this list will be reloaded every room swap.
this.userList = MatrixClientPeg.get().getUsers().filter(function(u) {
return !room.hasMembershipState(u.userId, "join");
});
},
onRoom: function(room) {
if (room.roomId !== this.props.roomId) {
return;
@ -96,6 +124,7 @@ module.exports = React.createClass({
// we need to wait till the room is fully populated with state
// before refreshing the member list else we get a stale list.
this._updateList();
this._loadUserList();
},
onRoomStateMember: function(ev, state, member) {
@ -131,12 +160,41 @@ module.exports = React.createClass({
return;
}
var promise;
var invite_defer = q.defer();
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
var history_visibility = room.currentState.getStateEvents('m.room.history_visibility', '');
if (history_visibility) history_visibility = history_visibility.getContent().history_visibility;
if (history_visibility == 'shared' && !shown_invite_warning_this_session) {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, {
title: "Warning",
description: SHARE_HISTORY_WARNING,
button: "Invite",
onFinished: function(should_invite) {
if (should_invite) {
shown_invite_warning_this_session = true;
invite_defer.resolve();
} else {
invite_defer.reject(null);
}
}
});
} else {
invite_defer.resolve();
}
var promise = invite_defer.promise;;
if (isEmailAddress) {
promise = MatrixClientPeg.get().inviteByEmail(this.props.roomId, inputText);
promise = promise.then(function() {
MatrixClientPeg.get().inviteByEmail(self.props.roomId, inputText);
});
}
else {
promise = MatrixClientPeg.get().invite(this.props.roomId, inputText);
promise = promise.then(function() {
MatrixClientPeg.get().invite(self.props.roomId, inputText);
});
}
self.setState({
@ -151,11 +209,13 @@ module.exports = React.createClass({
inviting: false
});
}, function(err) {
console.error("Failed to invite: %s", JSON.stringify(err));
Modal.createDialog(ErrorDialog, {
title: "Server error whilst inviting",
description: err.message
});
if (err !== null) {
console.error("Failed to invite: %s", JSON.stringify(err));
Modal.createDialog(ErrorDialog, {
title: "Server error whilst inviting",
description: err.message
});
}
self.setState({
inviting: false
});
@ -225,12 +285,23 @@ module.exports = React.createClass({
return latB - latA;
},
makeMemberTiles: function(membership) {
onSearchQueryChanged: function(input) {
this.setState({
searchQuery: input
});
},
makeMemberTiles: function(membership, query) {
var MemberTile = sdk.getComponent("rooms.MemberTile");
query = (query || "").toLowerCase();
var self = this;
return self.state.members.filter(function(userId) {
var memberList = self.state.members.filter(function(userId) {
var m = self.memberDict[userId];
if (query && m.name.toLowerCase().indexOf(query) !== 0) {
return false;
}
return m.membership == membership;
}).map(function(userId) {
var m = self.memberDict[userId];
@ -238,11 +309,32 @@ module.exports = React.createClass({
<MemberTile key={userId} member={m} ref={userId} />
);
});
},
onPopulateInvite: function(e) {
this.onInvite(this.refs.invite.value);
e.preventDefault();
if (membership === "invite") {
// include 3pid invites (m.room.third_party_invite) state events.
// The HS may have already converted these into m.room.member invites so
// we shouldn't add them if the 3pid invite state key (token) is in the
// member invite (content.third_party_invite.signed.token)
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
var EntityTile = sdk.getComponent("rooms.EntityTile");
if (room) {
room.currentState.getStateEvents("m.room.third_party_invite").forEach(
function(e) {
// discard all invites which have a m.room.member event since we've
// already added them.
var memberEvent = room.currentState.getInviteForThreePidToken(e.getStateKey());
if (memberEvent) {
return;
}
memberList.push(
<EntityTile key={e.getStateKey()} ref={e.getStateKey()}
name={e.getContent().display_name} />
)
})
}
}
return memberList;
},
inviteTile: function() {
@ -252,22 +344,25 @@ module.exports = React.createClass({
<Loader />
);
} else {
var SearchableEntityList = sdk.getComponent("rooms.SearchableEntityList");
return (
<form onSubmit={this.onPopulateInvite}>
<input className="mx_MemberList_invite" ref="invite" placeholder="Invite user (email)"/>
</form>
<SearchableEntityList searchPlaceholderText={"Invite / Search"}
onSubmit={this.onInvite}
onQueryChanged={this.onSearchQueryChanged}
entities={Entities.fromUsers(this.userList || [], true, this.onInvite)} />
);
}
},
render: function() {
var invitedSection = null;
var invitedMemberTiles = this.makeMemberTiles('invite');
var invitedMemberTiles = this.makeMemberTiles('invite', this.state.searchQuery);
if (invitedMemberTiles.length > 0) {
invitedSection = (
<div className="mx_MemberList_invited">
<h2>Invited</h2>
<div className="mx_MemberList_wrapper">
<div autoshow={true} className="mx_MemberList_wrapper">
{invitedMemberTiles}
</div>
</div>
@ -275,15 +370,17 @@ module.exports = React.createClass({
}
return (
<div className="mx_MemberList">
<GeminiScrollbar autoshow={true} className="mx_MemberList_border">
{this.inviteTile()}
<div>
<GeminiScrollbar autoshow={true} className="mx_MemberList_joined mx_MemberList_outerWrapper">
<div className="mx_MemberList_wrapper">
{this.makeMemberTiles('join')}
{this.makeMemberTiles('join', this.state.searchQuery)}
</div>
{invitedSection}
</GeminiScrollbar>
<div className="mx_MemberList_bottom">
<div className="mx_MemberList_bottomRule">
</div>
</div>
{invitedSection}
</GeminiScrollbar>
</div>
);
}

View file

@ -26,25 +26,20 @@ var Modal = require("../../../Modal");
module.exports = React.createClass({
displayName: 'MemberTile',
propTypes: {
member: React.PropTypes.any.isRequired, // RoomMember
},
getInitialState: function() {
return {};
},
onLeaveClick: function() {
dis.dispatch({
action: 'leave_room',
room_id: this.props.member.roomId,
});
this.props.onFinished();
},
shouldComponentUpdate: function(nextProps, nextState) {
if (this.state.hover !== nextState.hover) return true;
if (
this.member_last_modified_time === undefined ||
this.member_last_modified_time < nextProps.member.getLastModifiedTime()
) {
return true
return true;
}
if (
nextProps.member.user &&
@ -56,14 +51,6 @@ module.exports = React.createClass({
return false;
},
mouseEnter: function(e) {
this.setState({ 'hover': true });
},
mouseLeave: function(e) {
this.setState({ 'hover': false });
},
onClick: function(e) {
dis.dispatch({
action: 'view_user',
@ -71,114 +58,43 @@ module.exports = React.createClass({
});
},
getDuration: function(time) {
if (!time) return;
var t = parseInt(time / 1000);
var s = t % 60;
var m = parseInt(t / 60) % 60;
var h = parseInt(t / (60 * 60)) % 24;
var d = parseInt(t / (60 * 60 * 24));
if (t < 60) {
if (t < 0) {
return "0s";
}
return s + "s";
}
if (t < 60 * 60) {
return m + "m";
}
if (t < 24 * 60 * 60) {
return h + "h";
}
return d + "d ";
},
getPrettyPresence: function(user) {
if (!user) return "Unknown";
var presence = user.presence;
if (presence === "online") return "Online";
if (presence === "unavailable") return "Idle"; // XXX: is this actually right?
if (presence === "offline") return "Offline";
return "Unknown";
_getDisplayName: function() {
return this.props.member.name;
},
getPowerLabel: function() {
var label = this.props.member.userId;
if (this.state.isTargetMod) {
label += " - Mod (" + this.props.member.powerLevelNorm + "%)";
}
return label;
return this.props.member.userId + " (power " + this.props.member.powerLevel + ")";
},
render: function() {
this.member_last_modified_time = this.props.member.getLastModifiedTime();
if (this.props.member.user) {
this.user_last_modified_time = this.props.member.user.getLastModifiedTime();
}
var isMyUser = MatrixClientPeg.get().credentials.userId == this.props.member.userId;
var power;
// if (this.props.member && this.props.member.powerLevelNorm > 0) {
// var img = "img/p/p" + Math.floor(20 * this.props.member.powerLevelNorm / 100) + ".png";
// power = <img src={ img } className="mx_MemberTile_power" width="44" height="44" alt=""/>;
// }
var presenceClass = "mx_MemberTile_offline";
var mainClassName = "mx_MemberTile ";
if (this.props.member.user) {
if (this.props.member.user.presence === "online") {
presenceClass = "mx_MemberTile_online";
}
else if (this.props.member.user.presence === "unavailable") {
presenceClass = "mx_MemberTile_unavailable";
}
}
mainClassName += presenceClass;
if (this.state.hover) {
mainClassName += " mx_MemberTile_hover";
}
var name = this.props.member.name;
// if (isMyUser) name += " (me)"; // this does nothing other than introduce line wrapping and pain
//var leave = isMyUser ? <img className="mx_MemberTile_leave" src="img/delete.png" width="10" height="10" onClick={this.onLeaveClick}/> : null;
var nameEl;
if (this.state.hover) {
var presence;
// FIXME: make presence data update whenever User.presence changes...
var active = this.props.member.user ? ((Date.now() - (this.props.member.user.lastPresenceTs - this.props.member.user.lastActiveAgo)) || -1) : -1;
if (active >= 0) {
presence = <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">
<img className="mx_MemberTile_chevron" src="img/member_chevron.png" width="8" height="12"/>
<div className="mx_MemberTile_userId">{ name }</div>
{ presence }
</div>
}
else {
nameEl =
<div className="mx_MemberTile_name">
{ name }
</div>
}
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var EntityTile = sdk.getComponent('rooms.EntityTile');
var member = this.props.member;
var name = this._getDisplayName();
var active = -1;
var presenceState = member.user ? member.user.presence : null;
var av = (
<MemberAvatar member={member} width={36} height={36} />
);
if (member.user) {
this.user_last_modified_time = member.user.getLastModifiedTime();
// FIXME: make presence data update whenever User.presence changes...
active = (
(Date.now() - (member.user.lastPresenceTs - member.user.lastActiveAgo)) || -1
);
}
this.member_last_modified_time = member.getLastModifiedTime();
return (
<div className={mainClassName} title={ this.getPowerLabel() }
onClick={ this.onClick } onMouseEnter={ this.mouseEnter }
onMouseLeave={ this.mouseLeave }>
<div className="mx_MemberTile_avatar">
<MemberAvatar member={this.props.member} width={36} height={36} />
{ power }
</div>
{ nameEl }
</div>
<EntityTile {...this.props} presenceActiveAgo={active} presenceState={presenceState}
avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
shouldComponentUpdate={this.shouldComponentUpdate.bind(this)}
name={name} powerLevel={this.props.member.powerLevel} />
);
}
});

View file

@ -65,8 +65,17 @@ function mdownToHtml(mdown) {
module.exports = React.createClass({
displayName: 'MessageComposer',
statics: {
// the height we limit the composer to
MAX_HEIGHT: 100,
},
propTypes: {
tabComplete: React.PropTypes.any
tabComplete: React.PropTypes.any,
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
},
componentWillMount: function() {
@ -200,23 +209,18 @@ module.exports = React.createClass({
this.sentHistory.push(input);
this.onEnter(ev);
}
else if (ev.keyCode === KeyCode.UP) {
var input = this.refs.textarea.value;
var offset = this.refs.textarea.selectionStart || 0;
if (ev.ctrlKey || !input.substr(0, offset).match(/\n/)) {
this.sentHistory.next(1);
ev.preventDefault();
this.resizeInput();
}
}
else if (ev.keyCode === KeyCode.DOWN) {
var input = this.refs.textarea.value;
var offset = this.refs.textarea.selectionStart || 0;
if (ev.ctrlKey || !input.substr(offset).match(/\n/)) {
this.sentHistory.next(-1);
ev.preventDefault();
this.resizeInput();
}
else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var oldSelectionStart = this.refs.textarea.selectionStart;
// Remember the keyCode because React will recycle the synthetic event
var keyCode = ev.keyCode;
// set a callback so we can see if the cursor position changes as
// a result of this event. If it doesn't, we cycle history.
setTimeout(() => {
if (this.refs.textarea.selectionStart == oldSelectionStart) {
this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
this.resizeInput();
}
}, 0);
}
if (this.props.tabComplete) {
@ -237,13 +241,15 @@ module.exports = React.createClass({
// scrollHeight is at least equal to clientHeight, so we have to
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
this.refs.textarea.style.height = "0px";
var newHeight = this.refs.textarea.scrollHeight < 100 ? this.refs.textarea.scrollHeight : 100;
var newHeight = Math.min(this.refs.textarea.scrollHeight,
this.constructor.MAX_HEIGHT);
this.refs.textarea.style.height = Math.ceil(newHeight) + "px";
if (this.props.roomView) {
// kick gemini-scrollbar to re-layout
this.props.roomView.forceUpdate();
}
this.oldScrollHeight = this.refs.textarea.scrollHeight;
if (this.props.onResize) {
// kick gemini-scrollbar to re-layout
this.props.onResize();
}
},
onKeyUp: function(ev) {
@ -330,7 +336,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
}
sendMessagePromise.then(function() {
sendMessagePromise.done(function() {
dis.dispatch({
action: 'message_sent'
});
@ -461,6 +467,7 @@ module.exports = React.createClass({
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var callButton, videoCallButton, hangupButton;
var call = CallHandler.getCallForRoom(this.props.room.roomId);
@ -473,12 +480,12 @@ module.exports = React.createClass({
}
else {
callButton =
<div className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick}>
<img src="img/voice.svg" alt="Voice call" title="Voice call" width="16" height="26"/>
<div className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call">
<TintableSvg src="img/voice.svg" width="16" height="26"/>
</div>
videoCallButton =
<div className="mx_MessageComposer_videocall" onClick={this.onCallClick}>
<img src="img/call.svg" alt="Video call" title="Video call" width="30" height="22"/>
<div className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call">
<TintableSvg src="img/call.svg" width="30" height="22"/>
</div>
}
@ -492,8 +499,8 @@ module.exports = React.createClass({
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
<textarea ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
</div>
<div className="mx_MessageComposer_upload" onClick={this.onUploadClick}>
<img src="img/upload.svg" alt="Upload file" title="Upload file" width="19" height="24"/>
<div className="mx_MessageComposer_upload" onClick={this.onUploadClick} title="Upload file">
<TintableSvg src="img/upload.svg" width="19" height="24"/>
<input type="file" style={uploadInputStyle} ref="uploadInput" onChange={this.onUploadFileSelected} />
</div>
{ hangupButton }

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 MatrixClientPeg = require('../../../MatrixClientPeg');
var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix');
linkifyMatrix(linkify);
module.exports = React.createClass({
displayName: 'RoomHeader',
@ -41,6 +47,25 @@ module.exports = React.createClass({
};
},
componentWillReceiveProps: function(newProps) {
if (newProps.editing) {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
var name = this.props.room.currentState.getStateEvents('m.room.name', '');
this.setState({
name: name ? name.getContent().name : '',
defaultName: this.props.room.getDefaultRoomName(MatrixClientPeg.get().credentials.userId),
topic: topic ? topic.getContent().topic : '',
});
}
},
componentDidUpdate: function() {
if (this.refs.topic) {
linkifyElement(this.refs.topic, linkifyMatrix.options);
}
},
onVideoClick: function(e) {
dis.dispatch({
action: 'place_call',
@ -57,25 +82,59 @@ module.exports = React.createClass({
});
},
onNameChange: function(new_name) {
if (this.props.room.name != new_name && new_name) {
MatrixClientPeg.get().setRoomName(this.props.room.roomId, new_name);
onNameChanged: function(value) {
this.setState({ name : value });
},
onTopicChanged: function(value) {
this.setState({ topic : value });
},
onAvatarPickerClick: function(ev) {
if (this.refs.file_label) {
this.refs.file_label.click();
}
},
onAvatarSelected: function(ev) {
var self = this;
var changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!");
return;
}
changeAvatar.onFileSelected(ev).done(function() {
// dunno if the avatar changed, re-check it.
self._refreshFromServer();
}, function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: "Failed to set avatar. " + errMsg
});
});
},
getRoomName: function() {
return this.refs.name_edit.value;
return this.state.name;
},
getTopic: function() {
return this.state.topic;
},
render: function() {
var EditableText = sdk.getComponent("elements.EditableText");
var RoomAvatar = sdk.getComponent('avatars.RoomAvatar');
var RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var header;
if (this.props.simpleHeader) {
var cancel;
if (this.props.onCancelClick) {
cancel = <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 =
<div className="mx_RoomHeader_wrapper">
@ -86,27 +145,72 @@ module.exports = React.createClass({
</div>
}
else {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
var name = null;
var searchStatus = null;
var topic_el = null;
var cancel_button = null;
var save_button = null;
var settings_button = null;
var actual_name = this.props.room.currentState.getStateEvents('m.room.name', '');
if (actual_name) actual_name = actual_name.getContent().name;
if (this.props.editing) {
name =
<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 !== 'Empty room') {
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;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
@ -114,39 +218,93 @@ module.exports = React.createClass({
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;(~{ this.props.searchInfo.searchCount } results)</div>;
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
var members = this.props.room.getJoinedMembers();
var settingsHint = false;
if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) {
var name = this.props.room.currentState.getStateEvents('m.room.name', '');
if (!name || !name.getContent().name) {
settingsHint = true;
}
}
name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<div className="mx_RoomHeader_nametext" title={ this.props.room.name }>{ this.props.room.name }</div>
<div className={ "mx_RoomHeader_nametext " + (settingsHint ? "mx_RoomHeader_settingsHint" : "") } title={ this.props.room.name }>{ this.props.room.name }</div>
{ searchStatus }
<div className="mx_RoomHeader_settingsButton">
<img src="img/settings.svg" width="12" height="12"/>
<div className="mx_RoomHeader_settingsButton" title="Settings">
<TintableSvg src="img/settings.svg" width="12" height="12"/>
</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;
if (this.props.room) {
roomAvatar = (
<RoomAvatar room={this.props.room} width="48" height="48" />
);
if (can_set_room_avatar) {
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;
if (this.props.onLeaveClick) {
leave_button =
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton">
<img src="img/leave.svg" title="Leave room" alt="Leave room"
width="26" height="20" onClick={this.props.onLeaveClick}/>
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton" onClick={this.props.onLeaveClick} title="Leave room">
<TintableSvg src="img/leave.svg" width="26" height="20"/>
</div>;
}
var forget_button;
if (this.props.onForgetClick) {
forget_button =
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton">
<img src="img/leave.svg" title="Forget room" alt="Forget room"
width="26" height="20" onClick={this.props.onForgetClick}/>
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton" onClick={this.props.onForgetClick} title="Forget room">
<TintableSvg src="img/leave.svg" width="26" height="20"/>
</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>;
}
@ -161,20 +319,14 @@ module.exports = React.createClass({
{ topic_el }
</div>
</div>
{cancel_button}
{save_button}
<div className="mx_RoomHeader_rightRow">
{ forget_button }
{ leave_button }
<div className="mx_RoomHeader_button">
<img src="img/search.svg" title="Search" alt="Search" width="21" height="19" onClick={this.props.onSearchClick}/>
</div>
</div>
{cancel_button}
{right_row}
</div>
}
return (
<div className="mx_RoomHeader">
<div className={ "mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "") }>
{ header }
</div>
);

View file

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

View file

@ -16,23 +16,100 @@ limitations under the License.
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var Tinter = require('../../../Tinter');
var sdk = require('../../../index');
var Modal = require('../../../Modal');
var room_colors = [
// magic room default values courtesy of Ribot
["#76cfa6", "#eaf5f0"],
["#81bddb", "#eaf1f4"],
["#bd79cb", "#f3eaf5"],
["#c65d94", "#f5eaef"],
["#e55e5e", "#f5eaea"],
["#eca46f", "#f5eeea"],
["#dad658", "#f5f4ea"],
["#80c553", "#eef5ea"],
["#bb814e", "#eee8e3"],
["#595959", "#ececec"],
];
module.exports = React.createClass({
displayName: 'RoomSettings',
propTypes: {
room: React.PropTypes.object.isRequired,
onSaveClick: React.PropTypes.func,
onCancelClick: React.PropTypes.func,
},
getInitialState: function() {
// work out the initial color index
var room_color_index = undefined;
var color_scheme_event = this.props.room.getAccountData("org.matrix.room.color_scheme");
if (color_scheme_event) {
var color_scheme = color_scheme_event.getContent();
if (color_scheme.primary_color) color_scheme.primary_color = color_scheme.primary_color.toLowerCase();
if (color_scheme.secondary_color) color_scheme.secondary_color = color_scheme.secondary_color.toLowerCase();
// XXX: we should validate these values
for (var i = 0; i < room_colors.length; i++) {
var room_color = room_colors[i];
if (room_color[0] === color_scheme.primary_color &&
room_color[1] === color_scheme.secondary_color)
{
room_color_index = i;
break;
}
}
if (room_color_index === undefined) {
// append the unrecognised colours to our palette
room_color_index = room_colors.length;
room_colors[room_color_index] = [ color_scheme.primary_color, color_scheme.secondary_color ];
}
}
else {
room_color_index = 0;
}
// get the aliases
var aliases = {};
var domain = MatrixClientPeg.get().getDomain();
var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases');
for (var i = 0; i < alias_events.length; i++) {
aliases[alias_events[i].getStateKey()] = alias_events[i].getContent().aliases.slice(); // shallow copy
}
aliases[domain] = aliases[domain] || [];
var tags = {};
Object.keys(this.props.room.tags).forEach(function(tagName) {
tags[tagName] = {};
});
return {
power_levels_changed: false
power_levels_changed: false,
color_scheme_changed: false,
color_scheme_index: room_color_index,
aliases_changed: false,
aliases: aliases,
tags_changed: false,
tags: tags,
};
},
resetState: function() {
this.set.state(this.getInitialState());
},
canGuestsJoin: function() {
return this.refs.guests_join.checked;
},
canGuestsRead: function() {
return this.refs.guests_read.checked;
},
getTopic: function() {
return this.refs.topic.value;
return this.refs.topic ? this.refs.topic.value : "";
},
getJoinRules: function() {
@ -43,6 +120,10 @@ module.exports = React.createClass({
return this.refs.share_history.checked ? "shared" : "invited";
},
areNotificationsMuted: function() {
return this.refs.are_notifications_muted.checked;
},
getPowerLevels: function() {
if (!this.state.power_levels_changed) return undefined;
@ -50,13 +131,13 @@ module.exports = React.createClass({
power_levels = power_levels.getContent();
var new_power_levels = {
ban: parseInt(this.refs.ban.value),
kick: parseInt(this.refs.kick.value),
redact: parseInt(this.refs.redact.value),
invite: parseInt(this.refs.invite.value),
events_default: parseInt(this.refs.events_default.value),
state_default: parseInt(this.refs.state_default.value),
users_default: parseInt(this.refs.users_default.value),
ban: parseInt(this.refs.ban.getValue()),
kick: parseInt(this.refs.kick.getValue()),
redact: parseInt(this.refs.redact.getValue()),
invite: parseInt(this.refs.invite.getValue()),
events_default: parseInt(this.refs.events_default.getValue()),
state_default: parseInt(this.refs.state_default.getValue()),
users_default: parseInt(this.refs.users_default.getValue()),
users: power_levels.users,
events: power_levels.events,
};
@ -64,17 +145,231 @@ module.exports = React.createClass({
return new_power_levels;
},
getCanonicalAlias: function() {
return this.refs.canonical_alias ? this.refs.canonical_alias.value : "";
},
getAliasOperations: function() {
if (!this.state.aliases_changed) return undefined;
// work out the delta from room state to UI state
var ops = [];
// calculate original ("old") aliases
var oldAliases = {};
var aliases = this.state.aliases;
var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases');
for (var i = 0; i < alias_events.length; i++) {
var domain = alias_events[i].getStateKey();
oldAliases[domain] = alias_events[i].getContent().aliases.slice(); // shallow copy
}
// FIXME: this whole delta-based set comparison function used for domains, aliases & tags
// should be factored out asap rather than duplicated like this.
// work out whether any domains have entirely disappeared or appeared
var domainDelta = {}
Object.keys(oldAliases).forEach(function(domain) {
domainDelta[domain] = domainDelta[domain] || 0;
domainDelta[domain]--;
});
Object.keys(aliases).forEach(function(domain) {
domainDelta[domain] = domainDelta[domain] || 0;
domainDelta[domain]++;
});
Object.keys(domainDelta).forEach(function(domain) {
switch (domainDelta[domain]) {
case 1: // entirely new domain
aliases[domain].forEach(function(alias) {
ops.push({ type: "put", alias : alias });
});
break;
case -1: // entirely removed domain
oldAliases[domain].forEach(function(alias) {
ops.push({ type: "delete", alias : alias });
});
break;
case 0: // mix of aliases in this domain.
// compare old & new aliases for this domain
var delta = {};
oldAliases[domain].forEach(function(item) {
delta[item] = delta[item] || 0;
delta[item]--;
});
aliases[domain].forEach(function(item) {
delta[item] = delta[item] || 0;
delta[item]++;
});
Object.keys(delta).forEach(function(alias) {
if (delta[alias] == 1) {
ops.push({ type: "put", alias: alias });
} else if (delta[alias] == -1) {
ops.push({ type: "delete", alias: alias });
} else {
console.error("Calculated alias delta of " + delta[alias] +
" - this should never happen!");
}
});
break;
default:
console.error("Calculated domain delta of " + domainDelta[domain] +
" - this should never happen!");
break;
}
});
return ops;
},
getTagOperations: function() {
if (!this.state.tags_changed) return undefined;
var ops = [];
var delta = {};
Object.keys(this.props.room.tags).forEach(function(oldTag) {
delta[oldTag] = delta[oldTag] || 0;
delta[oldTag]--;
});
Object.keys(this.state.tags).forEach(function(newTag) {
delta[newTag] = delta[newTag] || 0;
delta[newTag]++;
});
Object.keys(delta).forEach(function(tag) {
if (delta[tag] == 1) {
ops.push({ type: "put", tag: tag });
} else if (delta[tag] == -1) {
ops.push({ type: "delete", tag: tag });
} else {
console.error("Calculated tag delta of " + delta[tag] +
" - this should never happen!");
}
});
return ops;
},
onPowerLevelsChanged: function() {
this.setState({
power_levels_changed: true
});
},
render: function() {
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
getColorScheme: function() {
if (!this.state.color_scheme_changed) return undefined;
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (topic) topic = topic.getContent().topic;
return {
primary_color: room_colors[this.state.color_scheme_index][0],
secondary_color: room_colors[this.state.color_scheme_index][1],
};
},
onColorSchemeChanged: function(index) {
// preview what the user just changed the scheme to.
Tinter.tint(room_colors[index][0], room_colors[index][1]);
this.setState({
color_scheme_changed: true,
color_scheme_index: index,
});
},
onAliasChanged: function(domain, index, alias) {
if (alias === "") return; // hit the delete button to delete please
var oldAlias;
if (this.isAliasValid(alias)) {
oldAlias = this.state.aliases[domain][index];
this.state.aliases[domain][index] = alias;
this.setState({ aliases_changed : true });
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid address format",
description: "'" + alias + "' is not a valid format for an address",
});
}
},
onAliasDeleted: function(domain, index) {
// It's a bit naughty to directly manipulate this.state, and React would
// normally whine at you, but it can't see us doing the splice. Given we
// promptly setState anyway, it's just about acceptable. The alternative
// would be to arbitrarily deepcopy to a temp variable and then setState
// that, but why bother when we can cut this corner.
var alias = this.state.aliases[domain].splice(index, 1);
this.setState({
aliases: this.state.aliases
});
this.setState({ aliases_changed : true });
},
onAliasAdded: function(alias) {
if (alias === "") return; // ignore attempts to create blank aliases
if (alias === undefined) {
alias = this.refs.add_alias ? this.refs.add_alias.getValue() : undefined;
if (alias === undefined || alias === "") return;
}
if (this.isAliasValid(alias)) {
var domain = alias.replace(/^.*?:/, '');
// XXX: do we need to deep copy aliases before editing it?
this.state.aliases[domain] = this.state.aliases[domain] || [];
this.state.aliases[domain].push(alias);
this.setState({
aliases: this.state.aliases
});
// reset the add field
this.refs.add_alias.setValue('');
this.setState({ aliases_changed : true });
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid alias format",
description: "'" + alias + "' is not a valid format for an alias",
});
}
},
isAliasValid: function(alias) {
// XXX: FIXME SPEC-1
return (alias.match(/^#([^\/:,]+?):(.+)$/) && encodeURI(alias) === alias);
},
onTagChange: function(tagName, event) {
if (event.target.checked) {
if (tagName === 'm.favourite') {
delete this.state.tags['m.lowpriority'];
}
else if (tagName === 'm.lowpriority') {
delete this.state.tags['m.favourite'];
}
this.state.tags[tagName] = this.state.tags[tagName] || {};
}
else {
delete this.state.tags[tagName];
}
// XXX: hacky say to deep-edit state
this.setState({
tags: this.state.tags,
tags_changed: true
});
},
render: function() {
// TODO: go through greying out things you don't have permission to change
// (or turning them into informative stuff)
var EditableText = sdk.getComponent('elements.EditableText');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', '');
if (join_rule) join_rule = join_rule.getContent().join_rule;
@ -83,8 +378,23 @@ module.exports = React.createClass({
if (history_visibility) history_visibility = history_visibility.getContent().history_visibility;
var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', '');
var guest_access = this.props.room.currentState.getStateEvents('m.room.guest_access', '');
if (guest_access) {
guest_access = guest_access.getContent().guest_access;
}
var are_notifications_muted;
var roomPushRule = MatrixClientPeg.get().getRoomPushRule("global", this.props.room.roomId);
if (roomPushRule) {
if (0 <= roomPushRule.actions.indexOf("dont_notify")) {
are_notifications_muted = true;
}
}
var events_levels = (power_levels ? power_levels.events : {}) || {};
var user_id = MatrixClientPeg.get().credentials.userId;
var events_levels = power_levels.events || {};
if (power_levels) {
power_levels = power_levels.getContent();
@ -103,8 +413,6 @@ module.exports = React.createClass({
var user_levels = power_levels.users || {};
var user_id = MatrixClientPeg.get().credentials.userId;
var current_user_level = user_levels[user_id];
if (current_user_level == undefined) current_user_level = default_user_level;
@ -133,104 +441,299 @@ module.exports = React.createClass({
var can_change_levels = false;
}
var room_avatar_level = parseInt(power_levels.state_default || 0);
if (events_levels['m.room.avatar'] !== undefined) {
room_avatar_level = events_levels['m.room.avatar'];
}
var can_set_room_avatar = current_user_level >= room_avatar_level;
var state_default = (parseInt(power_levels ? power_levels.state_default : 0) || 0);
var room_aliases_level = state_default;
if (events_levels['m.room.aliases'] !== undefined) {
room_avatar_level = events_levels['m.room.aliases'];
}
var can_set_room_aliases = current_user_level >= room_aliases_level;
var canonical_alias_level = state_default;
if (events_levels['m.room.canonical_alias'] !== undefined) {
room_avatar_level = events_levels['m.room.canonical_alias'];
}
var can_set_canonical_alias = current_user_level >= canonical_alias_level;
var tag_level = state_default;
if (events_levels['m.tag'] !== undefined) {
tag_level = events_levels['m.tag'];
}
var can_set_tag = current_user_level >= tag_level;
var self = this;
var canonical_alias_event = this.props.room.currentState.getStateEvents('m.room.canonical_alias', '');
var canonical_alias = canonical_alias_event ? canonical_alias_event.getContent().alias : "";
var domain = MatrixClientPeg.get().getDomain();
var remote_domains = Object.keys(this.state.aliases).filter(function(alias) { return alias !== domain });
var remote_aliases_section;
if (remote_domains.length) {
remote_aliases_section =
<div>
<div className="mx_RoomSettings_aliasLabel">
Remote addresses for this room:
</div>
<div className="mx_RoomSettings_aliasesTable">
{ remote_domains.map(function(state_key, i) {
return 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>
);
});
})}
</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 specified</option>
</select>
}
else {
canonical_alias_section = <b>{ canonical_alias || "not set" }</b>;
}
var aliases_section =
<div>
<h3>Addresses</h3>
<div className="mx_RoomSettings_aliasLabel">The main address for this room is: { canonical_alias_section }</div>
<div className="mx_RoomSettings_aliasLabel">
{ this.state.aliases[domain].length
? "Local addresses for this room:"
: "This room has no local addresses" }
</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 address (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 address (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 }
var change_avatar;
if (can_set_room_avatar) {
change_avatar = <div>
<h3>Room Icon</h3>
<ChangeAvatar room={this.props.room} />
</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 (Object.keys(user_levels).length) {
user_levels_section =
<div>
<h3>Privileged Users</h3>
<ul className="mx_RoomSettings_userLevels">
{Object.keys(user_levels).map(function(user, i) {
return (
<li className="mx_RoomSettings_userLevel" key={user}>
{ user } is a <PowerSelector value={ user_levels[user] } disabled={true}/>
</li>
);
})}
</ul>
</div>;
}
else {
user_levels_section = <div>No users have specific privileges in this room.</div>
}
var banned = this.props.room.getMembersWithMembership("ban");
var banned_users_section;
if (banned.length) {
banned_users_section =
<div>
<h3>Banned users</h3>
<ul className="mx_RoomSettings_banned">
{banned.map(function(member, i) {
return (
<li key={i}>
{member.userId}
</li>
);
})}
</ul>
</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">
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>
// FIXME: disable guests_read if the user hasn't turned on shared history
return (
<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="share_history" defaultChecked={history_visibility == "shared"}/> Share message history with new users</label> <br/>
<label className="mx_RoomSettings_encrypt"><input type="checkbox" /> Encrypt room</label> <br/>
<h3>Power levels</h3>
<div className="mx_RoomSettings_power_levels mx_RoomSettings_settings">
<div>
<label htmlFor="mx_RoomSettings_ban_level">Ban level</label>
<input type="text" defaultValue={ban_level} size="3" ref="ban" id="mx_RoomSettings_ban_level"
disabled={!can_change_levels || current_user_level < ban_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div>
<label htmlFor="mx_RoomSettings_kick_level">Kick level</label>
<input type="text" defaultValue={kick_level} size="3" ref="kick" id="mx_RoomSettings_kick_level"
disabled={!can_change_levels || current_user_level < kick_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div>
<label htmlFor="mx_RoomSettings_redact_level">Redact level</label>
<input type="text" defaultValue={redact_level} size="3" ref="redact" id="mx_RoomSettings_redact_level"
disabled={!can_change_levels || current_user_level < redact_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div>
<label htmlFor="mx_RoomSettings_invite_level">Invite level</label>
<input type="text" defaultValue={invite_level} size="3" ref="invite" id="mx_RoomSettings_invite_level"
disabled={!can_change_levels || current_user_level < invite_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div>
<label htmlFor="mx_RoomSettings_event_level">Send event level</label>
<input type="text" defaultValue={send_level} size="3" ref="events_default" id="mx_RoomSettings_event_level"
disabled={!can_change_levels || current_user_level < send_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div>
<label htmlFor="mx_RoomSettings_state_level">Set state level</label>
<input type="text" defaultValue={state_level} size="3" ref="state_default" id="mx_RoomSettings_state_level"
disabled={!can_change_levels || current_user_level < state_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div>
<label htmlFor="mx_RoomSettings_user_level">Default user level</label>
<input type="text" defaultValue={default_user_level} size="3" ref="users_default"
id="mx_RoomSettings_user_level" disabled={!can_change_levels || current_user_level < default_user_level}
onChange={this.onPowerLevelsChanged}/>
</div>
{ tags_section }
<div className="mx_RoomSettings_toggles">
<label><input type="checkbox" ref="are_notifications_muted" defaultChecked={are_notifications_muted}/> Mute notifications for this room</label>
<label><input type="checkbox" ref="is_private" defaultChecked={join_rule != "public"}/> Make this room private</label>
<label><input type="checkbox" ref="share_history" defaultChecked={history_visibility === "shared" || history_visibility === "world_readable"}/> Share message history with new participants</label>
<label><input type="checkbox" ref="guests_join" defaultChecked={guest_access === "can_join"}/> Let guests join this room</label>
<label><input type="checkbox" ref="guests_read" defaultChecked={history_visibility === "world_readable"}/> Let users read message history without joining</label>
<label className="mx_RoomSettings_encrypt"><input type="checkbox" /> Encrypt room</label>
</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">
{ room_colors_section }
{ aliases_section }
<h3>Permissions</h3>
<div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings">
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">The default role for new room members is </span>
<PowerSelector ref="users_default" value={default_user_level} disabled={!can_change_levels || current_user_level < default_user_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">To send messages, you must be a </span>
<PowerSelector ref="events_default" value={send_level} disabled={!can_change_levels || current_user_level < send_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">To invite users into the room, you must be a </span>
<PowerSelector ref="invite" value={invite_level} disabled={!can_change_levels || current_user_level < invite_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">To configure the room, you must be a </span>
<PowerSelector ref="state_default" value={state_level} disabled={!can_change_levels || current_user_level < state_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">To kick users, you must be a </span>
<PowerSelector ref="kick" value={kick_level} disabled={!can_change_levels || current_user_level < kick_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">To ban users, you must be a </span>
<PowerSelector ref="ban" value={ban_level} disabled={!can_change_levels || current_user_level < ban_level} onChange={this.onPowerLevelsChanged}/>
</div>
<div className="mx_RoomSettings_powerLevel">
<span className="mx_RoomSettings_powerLevelKey">To redact messages, you must be a </span>
<PowerSelector ref="redact" value={redact_level} disabled={!can_change_levels || current_user_level < redact_level} onChange={this.onPowerLevelsChanged}/>
</div>
{Object.keys(events_levels).map(function(event_type, i) {
return (
<div key={event_type}>
<label htmlFor={"mx_RoomSettings_event_"+i}>{event_type}</label>
<input type="text" defaultValue={events_levels[event_type]} size="3" id={"mx_RoomSettings_event_"+i} disabled/>
<div className="mx_RoomSettings_powerLevel" key={event_type}>
<span className="mx_RoomSettings_powerLevelKey">To send events of type <code>{ event_type }</code>, you must be a </span>
<PowerSelector value={ events_levels[event_type] } disabled={true} onChange={self.onPowerLevelsChanged}/>
</div>
);
})}
{ unfederatable_section }
</div>
<h3>Banned users</h3>
<div className="mx_RoomSettings_banned">
{banned.map(function(member, i) {
return (
<div key={i}>
{member.userId}
</div>
);
})}
{ user_levels_section }
{ banned_users_section }
<h3>Advanced</h3>
<div className="mx_RoomSettings_settings">
This room's internal ID is <code>{ this.props.room.roomId }</code>
</div>
{change_avatar}
</div>
);
}

View file

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

View file

@ -0,0 +1,122 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
var Modal = require("../../../Modal");
var GeminiScrollbar = require('react-gemini-scrollbar');
// A list capable of displaying entities which conform to the SearchableEntity
// interface which is an object containing getJsx(): Jsx and matches(query: string): boolean
var SearchableEntityList = React.createClass({
displayName: 'SearchableEntityList',
propTypes: {
searchPlaceholderText: React.PropTypes.string,
emptyQueryShowsAll: React.PropTypes.bool,
showInputBox: React.PropTypes.bool,
onQueryChanged: React.PropTypes.func, // fn(inputText)
onSubmit: React.PropTypes.func, // fn(inputText)
entities: React.PropTypes.array
},
getDefaultProps: function() {
return {
showInputBox: true,
searchPlaceholderText: "Search",
entities: [],
emptyQueryShowsAll: false,
onSubmit: function() {},
onQueryChanged: function(input) {}
};
},
getInitialState: function() {
return {
query: "",
results: this.getSearchResults("")
};
},
componentWillUnmount: function() {
// pretend the query box was blanked out else filters could still be
// applied to other components which rely on onQueryChanged.
this.props.onQueryChanged("");
},
/**
* Public-facing method to set the input query text to the given input.
* @param {string} input
*/
setQuery: function(input) {
this.setState({
query: input,
results: this.getSearchResults(input)
});
},
onQueryChanged: function(ev) {
var q = ev.target.value;
this.setState({
query: q,
results: this.getSearchResults(q)
});
this.props.onQueryChanged(q);
},
onQuerySubmit: function(ev) {
ev.preventDefault();
this.props.onSubmit(this.state.query);
},
getSearchResults: function(query) {
if (!query || query.length === 0) {
return this.props.emptyQueryShowsAll ? this.props.entities : []
}
return this.props.entities.filter(function(e) {
return e.matches(query);
});
},
render: function() {
var inputBox;
if (this.props.showInputBox) {
inputBox = (
<form onSubmit={this.onQuerySubmit}>
<input className="mx_SearchableEntityList_query" id="mx_SearchableEntityList_query" type="text"
onChange={this.onQueryChanged} value={this.state.query}
placeholder={this.props.searchPlaceholderText} />
</form>
);
}
return (
<div className={ "mx_SearchableEntityList " + (this.state.query.length ? "mx_SearchableEntityList_expanded" : "") }>
{inputBox}
<GeminiScrollbar autoshow={true} className="mx_SearchableEntityList_listWrapper">
<div className="mx_SearchableEntityList_list">
{this.state.results.map((entity) => {
return entity.getJsx();
})}
</div>
</GeminiScrollbar>
{ this.state.query.length ? <div className="mx_SearchableEntityList_hrWrapper"><hr/></div> : '' }
</div>
);
}
});
module.exports = SearchableEntityList;

View file

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

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');
var Avatar = require("../../../Avatar");
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
var Modal = require("../../../Modal");
module.exports = React.createClass({
displayName: 'UserTile',
propTypes: {
user: React.PropTypes.any.isRequired // User
},
render: function() {
var EntityTile = sdk.getComponent("rooms.EntityTile");
var user = this.props.user;
var name = user.displayName || user.userId;
var active = -1;
// FIXME: make presence data update whenever User.presence changes...
active = (
(Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) || -1
);
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var avatarJsx = (
<BaseAvatar width={36} height={36} name={name} idName={user.userId}
url={ Avatar.avatarUrlForUser(user, 36, 36, "crop") } />
);
return (
<EntityTile {...this.props} presenceState={user.presence} presenceActiveAgo={active}
name={name} title={user.userId} avatarJsx={avatarJsx} />
);
}
});

View file

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

View file

@ -99,7 +99,9 @@ module.exports = React.createClass({
var EditableText = sdk.getComponent('elements.EditableText');
return (
<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} />
);
}

View file

@ -33,6 +33,12 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({
displayName: 'CallView',
propTypes: {
// a callback which is called when the video within the callview
// due to a change in video metadata
onResize: React.PropTypes.func,
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
if (this.props.room) {
@ -97,7 +103,7 @@ module.exports = React.createClass({
render: function(){
var VideoView = sdk.getComponent('voip.VideoView');
return (
<VideoView ref="video" onClick={ this.props.onClick }/>
<VideoView ref="video" onClick={ this.props.onClick } onResize={ this.props.onResize }/>
);
}
});

View file

@ -21,9 +21,29 @@ var React = require('react');
module.exports = React.createClass({
displayName: 'VideoFeed',
propTypes: {
// a callback which is called when the video element is resized
// due to a change in video metadata
onResize: React.PropTypes.func,
},
componentDidMount() {
this.refs.vid.addEventListener('resize', this.onResize);
},
componentWillUnmount() {
this.refs.vid.removeEventListener('resize', this.onResize);
},
onResize: function(e) {
if(this.props.onResize) {
this.props.onResize(e);
}
},
render: function() {
return (
<video>
<video ref="vid">
</video>
);
},

View file

@ -85,7 +85,7 @@ module.exports = React.createClass({
return (
<div className="mx_VideoView" ref={this.setContainer} onClick={ this.props.onClick }>
<div className="mx_VideoView_remoteVideoFeed">
<VideoFeed ref="remote"/>
<VideoFeed ref="remote" onResize={this.props.onResize}/>
<audio ref="remoteAudio"/>
</div>
<div className="mx_VideoView_localVideoFeed">

23
src/email.js Normal file
View file

@ -0,0 +1,23 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var EMAIL_ADDRESS_REGEX = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
module.exports = {
looksValid: function(email) {
return EMAIL_ADDRESS_REGEX.test(email);
}
};