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

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'; 'use strict';
var ContentRepo = require("matrix-js-sdk").ContentRepo;
var MatrixClientPeg = require('./MatrixClientPeg'); var MatrixClientPeg = require('./MatrixClientPeg');
module.exports = { module.exports = {
@ -37,6 +37,17 @@ module.exports = {
return url; 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) { defaultAvatarUrlForString: function(s) {
var images = [ '76cfa6', '50e2c2', 'f4c371' ]; var images = [ '76cfa6', '50e2c2', 'f4c371' ];
var total = 0; 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 // A thing that holds your Matrix Client
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
var GuestAccess = require("./GuestAccess");
var matrixClient = null; var matrixClient = null;
@ -33,7 +34,7 @@ function deviceId() {
return id; 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 = { var opts = {
baseUrl: hs_url, baseUrl: hs_url,
idBaseUrl: is_url, idBaseUrl: is_url,
@ -47,6 +48,15 @@ function createClient(hs_url, is_url, user_id, access_token) {
} }
matrixClient = Matrix.createClient(opts); 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) { if (localStorage) {
@ -54,12 +64,18 @@ if (localStorage) {
var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org';
var access_token = localStorage.getItem("mx_access_token"); var access_token = localStorage.getItem("mx_access_token");
var user_id = localStorage.getItem("mx_user_id"); var user_id = localStorage.getItem("mx_user_id");
var guestAccess = new GuestAccess(localStorage);
if (access_token && user_id && hs_url) { 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 { class MatrixClient {
constructor(guestAccess) {
this.guestAccess = guestAccess;
}
get() { get() {
return matrixClient; 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) { if (localStorage) {
try { try {
localStorage.clear(); localStorage.clear();
@ -105,7 +121,8 @@ class MatrixClient {
console.warn("Error using local storage"); 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) { if (localStorage) {
try { try {
localStorage.setItem("mx_hs_url", hs_url); localStorage.setItem("mx_hs_url", hs_url);
@ -122,6 +139,6 @@ class MatrixClient {
} }
if (!global.mxMatrixClient) { if (!global.mxMatrixClient) {
global.mxMatrixClient = new MatrixClient(); global.mxMatrixClient = new MatrixClient(new GuestAccess(localStorage));
} }
module.exports = global.mxMatrixClient; 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; var old_state = this.state;
this.state = newState; this.state = newState;
if (MatrixClientPeg.get().isGuest()) {
return; // don't try to set presence when a guest; it won't work.
}
var self = this; var self = this;
MatrixClientPeg.get().setPresence(this.state).done(function() { MatrixClientPeg.get().setPresence(this.state).done(function() {
console.log("Presence: %s", newState); console.log("Presence: %s", newState);

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -36,7 +36,7 @@ module.exports = React.createClass({
var old = oldChildren[c.key]; var old = oldChildren[c.key];
var oldNode = ReactDom.findDOMNode(self.nodes[old.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() { Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() {
// special case visibility because it's nonsensical to animate an invisible element // special case visibility because it's nonsensical to animate an invisible element
// so we always hidden->visible pre-transition and visible->hidden after // so we always hidden->visible pre-transition and visible->hidden after
@ -73,6 +73,7 @@ module.exports = React.createClass({
collectNode: function(k, node) { collectNode: function(k, node) {
if ( if (
node &&
this.nodes[k] === undefined && this.nodes[k] === undefined &&
node.props.startStyle && node.props.startStyle &&
Object.keys(node.props.startStyle).length 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.ScrollPanel'] = require('./components/structures/ScrollPanel');
module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar');
module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings');
module.exports.components['structures.login.ForgotPassword'] = require('./components/structures/login/ForgotPassword');
module.exports.components['structures.login.Login'] = require('./components/structures/login/Login');
module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration');
module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration');
module.exports.components['views.avatars.BaseAvatar'] = require('./components/views/avatars/BaseAvatar');
module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar');
module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar'); module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar');
module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton'); module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton');
@ -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.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog');
module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); 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.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.EditableText'] = require('./components/views/elements/EditableText');
module.exports.components['views.elements.PowerSelector'] = require('./components/views/elements/PowerSelector');
module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar'); module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar');
module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg');
module.exports.components['views.elements.UserSelector'] = require('./components/views/elements/UserSelector'); 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.CaptchaForm'] = require('./components/views/login/CaptchaForm');
module.exports.components['views.login.CasLogin'] = require('./components/views/login/CasLogin'); 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.TextualBody'] = require('./components/views/messages/TextualBody');
module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent'); 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.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.EventTile'] = require('./components/views/rooms/EventTile');
module.exports.components['views.rooms.MemberInfo'] = require('./components/views/rooms/MemberInfo'); 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.MemberList'] = require('./components/views/rooms/MemberList');
module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile'); module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile');
module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer'); module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer');
module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel');
module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader'); module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader');
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList');
module.exports.components['views.rooms.RoomPreviewBar'] = require('./components/views/rooms/RoomPreviewBar');
module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings'); module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings');
module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile');
module.exports.components['views.rooms.SearchableEntityList'] = require('./components/views/rooms/SearchableEntityList');
module.exports.components['views.rooms.SearchResultTile'] = require('./components/views/rooms/SearchResultTile'); 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.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.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); 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 UserSelector = sdk.getComponent("elements.UserSelector");
var RoomHeader = sdk.getComponent("rooms.RoomHeader"); var RoomHeader = sdk.getComponent("rooms.RoomHeader");
var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); var domain = MatrixClientPeg.get().getDomain();
return ( return (
<div className="mx_CreateRoom"> <div className="mx_CreateRoom">

View file

@ -31,6 +31,7 @@ var Registration = require("./login/Registration");
var PostRegistration = require("./login/PostRegistration"); var PostRegistration = require("./login/PostRegistration");
var Modal = require("../../Modal"); var Modal = require("../../Modal");
var Tinter = require("../../Tinter");
var sdk = require('../../index'); var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools'); var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix"); var linkifyMatrix = require("../../linkify-matrix");
@ -43,6 +44,7 @@ module.exports = React.createClass({
ConferenceHandler: React.PropTypes.any, ConferenceHandler: React.PropTypes.any,
onNewScreen: React.PropTypes.func, onNewScreen: React.PropTypes.func,
registrationUrl: React.PropTypes.string, registrationUrl: React.PropTypes.string,
enableGuest: React.PropTypes.bool,
startingQueryParams: React.PropTypes.object startingQueryParams: React.PropTypes.object
}, },
@ -63,7 +65,8 @@ module.exports = React.createClass({
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
ready: 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 (s.logged_in) {
if (MatrixClientPeg.get().getRooms().length) { if (MatrixClientPeg.get().getRooms().length) {
@ -88,8 +91,21 @@ module.exports = React.createClass({
}, },
componentDidMount: function() { 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); this.dispatcherRef = dis.register(this.onAction);
if (this.state.logged_in) { 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.startMatrixClient();
} }
this.focusComposer = false; this.focusComposer = false;
@ -98,8 +114,11 @@ module.exports = React.createClass({
this.scrollStateMap = {}; this.scrollStateMap = {};
document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keydown", this.onKeyDown);
window.addEventListener("focus", this.onFocus); window.addEventListener("focus", this.onFocus);
if (this.state.logged_in) { if (this.state.logged_in) {
this.notifyNewScreen(''); this.notifyNewScreen('');
} else if (this._autoRegisterAsGuest) {
this._registerAsGuest();
} else { } else {
this.notifyNewScreen('login'); 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) { onAction: function(payload) {
var roomIndexDelta = 1; var roomIndexDelta = 1;
@ -185,6 +232,21 @@ module.exports = React.createClass({
screen: 'post_registration' screen: 'post_registration'
}); });
break; 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': case 'token_login':
if (this.state.logged_in) return; if (this.state.logged_in) return;
@ -248,7 +310,10 @@ module.exports = React.createClass({
}); });
break; break;
case 'view_room': 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; break;
case 'view_prev_room': case 'view_prev_room':
roomIndexDelta = -1; roomIndexDelta = -1;
@ -301,8 +366,29 @@ module.exports = React.createClass({
this.notifyNewScreen('settings'); this.notifyNewScreen('settings');
break; break;
case 'view_create_room': case 'view_create_room':
this._setPage(this.PageTypes.CreateRoom); //this._setPage(this.PageTypes.CreateRoom);
this.notifyNewScreen('new'); //this.notifyNewScreen('new');
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader);
MatrixClientPeg.get().createRoom({
preset: "private_chat"
}).done(function(res) {
modal.close();
dis.dispatch({
action: 'view_room',
room_id: res.room_id,
// show_settings: true,
});
}, function(err) {
modal.close();
Modal.createDialog(ErrorDialog, {
title: "Failed to create room",
description: err.toString()
});
});
break; break;
case 'view_room_directory': case 'view_room_directory':
this._setPage(this.PageTypes.RoomDirectory); this._setPage(this.PageTypes.RoomDirectory);
@ -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 // before we switch room, record the scroll state of the current room
this._updateScrollMap(); this._updateScrollMap();
@ -363,7 +449,16 @@ module.exports = React.createClass({
if (room) { if (room) {
var theAlias = MatrixTools.getCanonicalAliasForRoom(room); var theAlias = MatrixTools.getCanonicalAliasForRoom(room);
if (theAlias) presentedId = theAlias; if (theAlias) presentedId = theAlias;
var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
var color_scheme = {};
if (color_scheme_event) {
color_scheme = color_scheme_event.getContent();
// XXX: we should validate the event
} }
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}
this.notifyNewScreen('room/'+presentedId); this.notifyNewScreen('room/'+presentedId);
newState.ready = true; newState.ready = true;
} }
@ -372,6 +467,9 @@ module.exports = React.createClass({
var scrollState = this.scrollStateMap[roomId]; var scrollState = this.scrollStateMap[roomId];
this.refs.roomView.restoreScrollState(scrollState); this.refs.roomView.restoreScrollState(scrollState);
} }
if (this.refs.roomView && showSettings) {
this.refs.roomView.showSettings(true);
}
}, },
// update scrollStateMap according to the current scroll state of the // update scrollStateMap according to the current scroll state of the
@ -387,10 +485,11 @@ module.exports = React.createClass({
}, },
onLoggedIn: function(credentials) { 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( MatrixClientPeg.replaceUsingAccessToken(
credentials.homeserverUrl, credentials.identityServerUrl, credentials.homeserverUrl, credentials.identityServerUrl,
credentials.userId, credentials.accessToken credentials.userId, credentials.accessToken, credentials.guest
); );
this.setState({ this.setState({
screen: undefined, screen: undefined,
@ -457,7 +556,9 @@ module.exports = React.createClass({
UserActivity.start(); UserActivity.start();
Presence.start(); Presence.start();
cli.startClient({ cli.startClient({
pendingEventOrdering: "end" pendingEventOrdering: "end",
// deliberately huge limit for now to avoid hitting gappy /sync's until gappy /sync performance improves
initialSyncLimit: 250,
}); });
}, },
@ -511,6 +612,11 @@ module.exports = React.createClass({
action: 'token_login', action: 'token_login',
params: params params: params
}); });
} else if (screen == 'forgot_password') {
dis.dispatch({
action: 'start_password_recovery',
params: params
});
} else if (screen == 'new') { } else if (screen == 'new') {
dis.dispatch({ dis.dispatch({
action: 'view_create_room', action: 'view_create_room',
@ -566,6 +672,8 @@ module.exports = React.createClass({
onUserClick: function(event, userId) { onUserClick: function(event, userId) {
event.preventDefault(); event.preventDefault();
/*
var MemberInfo = sdk.getComponent('rooms.MemberInfo'); var MemberInfo = sdk.getComponent('rooms.MemberInfo');
var member = new Matrix.RoomMember(null, userId); var member = new Matrix.RoomMember(null, userId);
ContextualMenu.createMenu(MemberInfo, { ContextualMenu.createMenu(MemberInfo, {
@ -573,6 +681,14 @@ module.exports = React.createClass({
right: window.innerWidth - event.pageX, right: window.innerWidth - event.pageX,
top: event.pageY top: event.pageY
}); });
*/
var member = new Matrix.RoomMember(null, userId);
if (!member) { return; }
dis.dispatch({
action: 'view_user',
member: member,
});
}, },
onLogoutClick: function(event) { onLogoutClick: function(event) {
@ -620,10 +736,22 @@ module.exports = React.createClass({
this.showScreen("login"); this.showScreen("login");
}, },
onForgotPasswordClick: function() {
this.showScreen("forgot_password");
},
onRegistered: function(credentials) { onRegistered: function(credentials) {
this.onLoggedIn(credentials); this.onLoggedIn(credentials);
// do post-registration stuff // do post-registration stuff
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() { onFinishPostRegistration: function() {
@ -639,7 +767,9 @@ module.exports = React.createClass({
var rooms = MatrixClientPeg.get().getRooms(); var rooms = MatrixClientPeg.get().getRooms();
for (var i = 0; i < rooms.length; ++i) { 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(); notifCount += rooms[i].getUnreadNotificationCount();
} }
} }
@ -671,6 +801,7 @@ module.exports = React.createClass({
var CreateRoom = sdk.getComponent('structures.CreateRoom'); var CreateRoom = sdk.getComponent('structures.CreateRoom');
var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); var RoomDirectory = sdk.getComponent('structures.RoomDirectory');
var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar');
var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword');
// needs to be before normal PageTypes as you are logged in technically // needs to be before normal PageTypes as you are logged in technically
if (this.state.screen == 'post_registration') { if (this.state.screen == 'post_registration') {
@ -689,6 +820,7 @@ module.exports = React.createClass({
<RoomView <RoomView
ref="roomView" ref="roomView"
roomId={this.state.currentRoom} roomId={this.state.currentRoom}
autoPeek={this.state.autoPeek}
key={this.state.currentRoom} key={this.state.currentRoom}
ConferenceHandler={this.props.ConferenceHandler} /> ConferenceHandler={this.props.ConferenceHandler} />
); );
@ -734,12 +866,20 @@ module.exports = React.createClass({
</div> </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 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 ( return (
<div className="mx_MatrixChat_splash"> <div className="mx_MatrixChat_splash">
<Spinner /> <Spinner />
<a href="#" className="mx_MatrixChat_splashButtons" onClick={ this.onLogoutClick }>Logout</a> {logoutLink}
</div> </div>
); );
} else if (this.state.screen == 'register') { } else if (this.state.screen == 'register') {
@ -749,19 +889,30 @@ module.exports = React.createClass({
sessionId={this.state.register_session_id} sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid} idSid={this.state.register_id_sid}
email={this.props.startingQueryParams.email} 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} hsUrl={this.props.config.default_hs_url}
isUrl={this.props.config.default_is_url} isUrl={this.props.config.default_is_url}
registrationUrl={this.props.registrationUrl} registrationUrl={this.props.registrationUrl}
onLoggedIn={this.onRegistered} onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick} /> onLoginClick={this.onLoginClick} />
); );
} else if (this.state.screen == 'forgot_password') {
return (
<ForgotPassword
homeserverUrl={this.props.config.default_hs_url}
identityServerUrl={this.props.config.default_is_url}
onComplete={this.onLoginClick} />
);
} else { } else {
return ( return (
<Login <Login
onLoggedIn={this.onLoggedIn} onLoggedIn={this.onLoggedIn}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
homeserverUrl={this.props.config.default_hs_url} homeserverUrl={this.props.config.default_hs_url}
identityServerUrl={this.props.config.default_is_url} /> identityServerUrl={this.props.config.default_is_url}
onForgotPasswordClick={this.onForgotPasswordClick} />
); );
} }
} }

View file

@ -35,11 +35,15 @@ var sdk = require('../../index');
var CallHandler = require('../../CallHandler'); var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete"); var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry; var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
var CommandEntry = require("../../TabCompleteEntries").CommandEntry;
var Resend = require("../../Resend"); var Resend = require("../../Resend");
var SlashCommands = require("../../SlashCommands");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var Tinter = require("../../Tinter");
var PAGINATE_SIZE = 20; var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20; var INITIAL_SIZE = 20;
var SEND_READ_RECEIPT_DELAY = 2000;
var DEBUG_SCROLL = false; var DEBUG_SCROLL = false;
@ -53,7 +57,9 @@ if (DEBUG_SCROLL) {
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomView', displayName: 'RoomView',
propTypes: { propTypes: {
ConferenceHandler: React.PropTypes.any ConferenceHandler: React.PropTypes.any,
roomId: React.PropTypes.string,
autoPeek: React.PropTypes.bool, // should we try to peek the room on mount, or has whoever invoked us already initiated a peek?
}, },
/* properties in RoomView objects include: /* properties in RoomView objects include:
@ -74,13 +80,21 @@ module.exports = React.createClass({
syncState: MatrixClientPeg.get().getSyncState(), syncState: MatrixClientPeg.get().getSyncState(),
hasUnsentMessages: this._hasUnsentMessages(room), hasUnsentMessages: this._hasUnsentMessages(room),
callState: null, callState: null,
autoPeekDone: false, // track whether our autoPeek (if any) has completed)
guestsCanJoin: false,
canPeek: false,
readMarkerEventId: room ? room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId) : null,
readMarkerGhostEventId: undefined
} }
}, },
componentWillMount: function() { componentWillMount: function() {
this.last_rr_sent_event_id = undefined;
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room", this.onNewRoom);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
@ -88,8 +102,6 @@ module.exports = React.createClass({
// xchat-style tab complete, add a colon if tab // xchat-style tab complete, add a colon if tab
// completing at the start of the text // completing at the start of the text
this.tabComplete = new TabComplete({ this.tabComplete = new TabComplete({
startingWordSuffix: ": ",
wordSuffix: " ",
allowLooping: false, allowLooping: false,
autoEnterTabComplete: true, autoEnterTabComplete: true,
onClickCompletes: true, onClickCompletes: true,
@ -97,24 +109,56 @@ module.exports = React.createClass({
this.forceUpdate(); this.forceUpdate();
} }
}); });
// if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek)
// - This is a room we can publicly join or were invited to. (we can /join)
// - This is a room we cannot join at all. (no action can help us)
// We can't try to /join because this may implicitly accept invites (!)
// We can /peek though. If it fails then we present the join UI. If it
// succeeds then great, show the preview (but we still may be able to /join!).
if (!this.state.room) {
if (this.props.autoPeek) {
console.log("Attempting to peek into room %s", this.props.roomId);
MatrixClientPeg.get().peekInRoom(this.props.roomId).catch((err) => {
console.error("Failed to peek into room: %s", err);
}).finally(() => {
// we don't need to do anything - JS SDK will emit Room events
// which will update the UI.
this.setState({
autoPeekDone: true
});
});
}
}
else {
this._calculatePeekRules(this.state.room);
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
if (this.refs.messagePanel) { // set a boolean to say we've been unmounted, which any pending
// disconnect the D&D event listeners from the message panel. This // promises can use to throw away their results.
// is really just for hygiene - the messagePanel is going to be //
// (We could use isMounted, but facebook have deprecated that.)
this.unmounted = true;
if (this.refs.roomView) {
// disconnect the D&D event listeners from the room view. This
// is really just for hygiene - we're going to be
// deleted anyway, so it doesn't matter if the event listeners // deleted anyway, so it doesn't matter if the event listeners
// don't get cleaned up. // don't get cleaned up.
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); var roomView = ReactDOM.findDOMNode(this.refs.roomView);
messagePanel.removeEventListener('drop', this.onDrop); roomView.removeEventListener('drop', this.onDrop);
messagePanel.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragover', this.onDragOver);
messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd);
messagePanel.removeEventListener('dragend', this.onDragLeaveOrEnd); roomView.removeEventListener('dragend', this.onDragLeaveOrEnd);
} }
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room", this.onNewRoom);
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
@ -122,6 +166,8 @@ module.exports = React.createClass({
} }
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
Tinter.tint(); // reset colourscheme
}, },
onAction: function(payload) { onAction: function(payload) {
@ -175,6 +221,12 @@ module.exports = React.createClass({
break; break;
case 'user_activity': case 'user_activity':
case 'user_activity_end':
// we could treat user_activity_end differently and not
// send receipts for messages that have arrived between
// the actual user activity and the time they stopped
// being active, but let's see if this is actually
// necessary.
this.sendReadReceipt(); this.sendReadReceipt();
break; break;
} }
@ -196,7 +248,7 @@ module.exports = React.createClass({
},*/ },*/
onRoomTimeline: function(ev, room, toStartOfTimeline) { onRoomTimeline: function(ev, room, toStartOfTimeline) {
if (!this.isMounted()) return; if (this.unmounted) return;
// ignore anything that comes in whilst paginating: we get one // ignore anything that comes in whilst paginating: we get one
// event for each new matrix event so this would cause a huge // event for each new matrix event so this would cause a huge
@ -227,6 +279,32 @@ module.exports = React.createClass({
}); });
}, },
onNewRoom: function(room) {
if (room.roomId == this.props.roomId) {
this.setState({
room: room
});
}
this._calculatePeekRules(room);
},
_calculatePeekRules: function(room) {
var guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", "");
if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") {
this.setState({
guestsCanJoin: true
});
}
var historyVisibility = room.currentState.getStateEvents("m.room.history_visibility", "");
if (historyVisibility && historyVisibility.getContent().history_visibility === "world_readable") {
this.setState({
canPeek: true
});
}
},
onRoomName: function(room) { onRoomName: function(room) {
if (room.roomId == this.props.roomId) { if (room.roomId == this.props.roomId) {
this.setState({ this.setState({
@ -235,9 +313,58 @@ module.exports = React.createClass({
} }
}, },
updateTint: function() {
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
if (!room) return;
var color_scheme_event = room.getAccountData("org.matrix.room.color_scheme");
var color_scheme = {};
if (color_scheme_event) {
color_scheme = color_scheme_event.getContent();
// XXX: we should validate the event
}
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
},
onRoomAccountData: function(room, event) {
if (room.roomId == this.props.roomId) {
if (event.getType === "org.matrix.room.color_scheme") {
var color_scheme = event.getContent();
// XXX: we should validate the event
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}
}
},
onRoomReceipt: function(receiptEvent, room) { onRoomReceipt: function(receiptEvent, room) {
if (room.roomId == this.props.roomId) { if (room.roomId == this.props.roomId) {
this.forceUpdate(); var readMarkerEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
var readMarkerGhostEventId = this.state.readMarkerGhostEventId;
if (this.state.readMarkerEventId !== undefined && this.state.readMarkerEventId != readMarkerEventId) {
readMarkerGhostEventId = this.state.readMarkerEventId;
}
// if the event after the one referenced in the read receipt if sent by us, do nothing since
// this is a temporary period before the synthesized receipt for our own message arrives
var readMarkerGhostEventIndex;
for (var i = 0; i < room.timeline.length; ++i) {
if (room.timeline[i].getId() == readMarkerGhostEventId) {
readMarkerGhostEventIndex = i;
break;
}
}
if (readMarkerGhostEventIndex + 1 < room.timeline.length) {
var nextEvent = room.timeline[readMarkerGhostEventIndex + 1];
if (nextEvent.sender && nextEvent.sender.userId == MatrixClientPeg.get().credentials.userId) {
readMarkerGhostEventId = undefined;
}
}
this.setState({
readMarkerEventId: readMarkerEventId,
readMarkerGhostEventId: readMarkerGhostEventId,
});
} }
}, },
@ -249,6 +376,14 @@ module.exports = React.createClass({
if (member.roomId === this.props.roomId) { if (member.roomId === this.props.roomId) {
// a member state changed in this room, refresh the tab complete list // a member state changed in this room, refresh the tab complete list
this._updateTabCompleteList(this.state.room); this._updateTabCompleteList(this.state.room);
var room = MatrixClientPeg.get().getRoom(this.props.roomId);
var me = MatrixClientPeg.get().credentials.userId;
if (this.state.joining && room.hasMembershipState(me, "join")) {
this.setState({
joining: false
});
}
} }
if (!this.props.ConferenceHandler) { if (!this.props.ConferenceHandler) {
@ -314,7 +449,24 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
this.onResize(); this.onResize();
if (this.refs.roomView) {
var roomView = ReactDOM.findDOMNode(this.refs.roomView);
roomView.addEventListener('drop', this.onDrop);
roomView.addEventListener('dragover', this.onDragOver);
roomView.addEventListener('dragleave', this.onDragLeaveOrEnd);
roomView.addEventListener('dragend', this.onDragLeaveOrEnd);
}
this._updateTabCompleteList(this.state.room); this._updateTabCompleteList(this.state.room);
// XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer.
if (this.state.room && this.state.room.getJoinedMembers().length == 1) {
var inviteBox = document.getElementById("mx_SearchableEntityList_query");
setTimeout(function() {
inviteBox.focus();
}, 50);
}
}, },
_updateTabCompleteList: function(room) { _updateTabCompleteList: function(room) {
@ -322,7 +474,9 @@ module.exports = React.createClass({
return; return;
} }
this.tabComplete.setCompletionList( this.tabComplete.setCompletionList(
MemberEntry.fromMemberList(room.getJoinedMembers()) MemberEntry.fromMemberList(room.getJoinedMembers()).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
)
); );
}, },
@ -330,13 +484,10 @@ module.exports = React.createClass({
var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel);
this.refs.messagePanel.initialised = true; this.refs.messagePanel.initialised = true;
messagePanel.addEventListener('drop', this.onDrop);
messagePanel.addEventListener('dragover', this.onDragOver);
messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd);
messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd);
this.scrollToBottom(); this.scrollToBottom();
this.sendReadReceipt(); this.sendReadReceipt();
this.updateTint();
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
@ -353,11 +504,14 @@ module.exports = React.createClass({
_paginateCompleted: function() { _paginateCompleted: function() {
debuglog("paginate complete"); debuglog("paginate complete");
this.setState({ // we might have switched rooms since the paginate started - just bin
room: MatrixClientPeg.get().getRoom(this.props.roomId) // the results if so.
}); if (this.unmounted) return;
this.setState({paginating: false}); this.setState({
room: MatrixClientPeg.get().getRoom(this.props.roomId),
paginating: false,
});
}, },
onSearchResultsFillRequest: function(backwards) { onSearchResultsFillRequest: function(backwards) {
@ -412,16 +566,29 @@ module.exports = React.createClass({
onJoinButtonClicked: function(ev) { onJoinButtonClicked: function(ev) {
var self = this; var self = this;
MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() { MatrixClientPeg.get().joinRoom(this.props.roomId).done(function() {
// It is possible that there is no Room yet if state hasn't come down
// from /sync - joinRoom will resolve when the HTTP request to join succeeds,
// NOT when it comes down /sync. If there is no room, we'll keep the
// joining flag set until we see it. Likewise, if our state is not
// "join" we'll keep this flag set until it comes down /sync.
var room = MatrixClientPeg.get().getRoom(self.props.roomId);
var me = MatrixClientPeg.get().credentials.userId;
self.setState({ self.setState({
joining: false, joining: room ? !room.hasMembershipState(me, "join") : true,
room: MatrixClientPeg.get().getRoom(self.props.roomId) room: room
}); });
}, function(error) { }, function(error) {
self.setState({ self.setState({
joining: false, joining: false,
joinError: error joinError: error
}); });
var msg = error.message ? error.message : JSON.stringify(error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Failed to join room",
description: msg
});
}); });
this.setState({ this.setState({
joining: true joining: true
@ -535,7 +702,7 @@ module.exports = React.createClass({
return searchPromise.then(function(results) { return searchPromise.then(function(results) {
debuglog("search complete"); debuglog("search complete");
if (!self.state.searching || self.searchId != localSearchId) { if (self.unmounted || !self.state.searching || self.searchId != localSearchId) {
console.error("Discarding stale search results"); console.error("Discarding stale search results");
return; return;
} }
@ -553,7 +720,8 @@ module.exports = React.createClass({
// For overlapping highlights, // For overlapping highlights,
// favour longer (more specific) terms first // favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) { b.length - a.length }); highlights = highlights.sort(function(a, b) {
return b.length - a.length });
self.setState({ self.setState({
searchHighlights: highlights, searchHighlights: highlights,
@ -648,9 +816,10 @@ module.exports = React.createClass({
var EventTile = sdk.getComponent('rooms.EventTile'); var EventTile = sdk.getComponent('rooms.EventTile');
var prevEvent = null; // the last event we showed var prevEvent = null; // the last event we showed
var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap); var startIdx = Math.max(0, this.state.room.timeline.length - this.state.messageCap);
var readMarkerIndex;
var ghostIndex;
for (var i = startIdx; i < this.state.room.timeline.length; i++) { for (var i = startIdx; i < this.state.room.timeline.length; i++) {
var mxEv = this.state.room.timeline[i]; var mxEv = this.state.room.timeline[i];
@ -664,6 +833,25 @@ module.exports = React.createClass({
} }
} }
// now we've decided whether or not to show this message,
// add the read up to marker if appropriate
// doing this here means we implicitly do not show the marker
// if it's at the bottom
// NB. it would be better to decide where the read marker was going
// when the state changed rather than here in the render method, but
// this is where we decide what messages we show so it's the only
// place we know whether we're at the bottom or not.
var self = this;
var mxEvSender = mxEv.sender ? mxEv.sender.userId : null;
if (prevEvent && prevEvent.getId() == this.state.readMarkerEventId && mxEvSender != MatrixClientPeg.get().credentials.userId) {
var hr;
hr = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) {
self.readMarkerNode = n;
}} />);
readMarkerIndex = ret.length;
ret.push(<li key="_readupto" className="mx_RoomView_myReadMarker_container">{hr}</li>);
}
// is this a continuation of the previous message? // is this a continuation of the previous message?
var continuation = false; var continuation = false;
if (prevEvent !== null) { if (prevEvent !== null) {
@ -700,13 +888,33 @@ module.exports = React.createClass({
</li> </li>
); );
// A read up to marker has died and returned as a ghost!
// Lives in the dom as the ghost of the previous one while it fades away
if (eventId == this.state.readMarkerGhostEventId) {
ghostIndex = ret.length;
}
prevEvent = mxEv; prevEvent = mxEv;
} }
// splice the read marker ghost in now that we know whether the read receipt
// is the last element or not, because we only decide as we're going along.
if (readMarkerIndex === undefined && ghostIndex && ghostIndex <= ret.length) {
var hr;
hr = (<hr className="mx_RoomView_myReadMarker" style={{opacity: 1, width: '99%'}} ref={function(n) {
Velocity(n, {opacity: '0', width: '10%'}, {duration: 400, easing: 'easeInSine', delay: 1000, complete: function() {
self.setState({readMarkerGhostEventId: undefined});
}});
}} />);
ret.splice(ghostIndex, 0, (
<li key="_readuptoghost" className="mx_RoomView_myReadMarker_container">{hr}</li>
));
}
return ret; return ret;
}, },
uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels) { uploadNewState: function(newVals) {
var old_name = this.state.room.name; var old_name = this.state.room.name;
var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', ''); var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', '');
@ -730,56 +938,201 @@ module.exports = React.createClass({
old_history_visibility = "shared"; old_history_visibility = "shared";
} }
var old_guest_read = (old_history_visibility === "world_readable");
var old_guest_join = this.state.room.currentState.getStateEvents('m.room.guest_access', '');
if (old_guest_join) {
old_guest_join = (old_guest_join.getContent().guest_access === "can_join");
}
else {
old_guest_join = false;
}
var old_canonical_alias = this.state.room.currentState.getStateEvents('m.room.canonical_alias', '');
if (old_canonical_alias) {
old_canonical_alias = old_canonical_alias.getContent().alias;
}
else {
old_canonical_alias = "";
}
var deferreds = []; var deferreds = [];
if (old_name != new_name && new_name != undefined && new_name) { if (old_name != newVals.name && newVals.name != undefined) {
deferreds.push( deferreds.push(
MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name) MatrixClientPeg.get().setRoomName(this.state.room.roomId, newVals.name)
); );
} }
if (old_topic != new_topic && new_topic != undefined) { if (old_topic != newVals.topic && newVals.topic != undefined) {
deferreds.push( deferreds.push(
MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic) MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, newVals.topic)
); );
} }
if (old_join_rule != new_join_rule && new_join_rule != undefined) { if (old_join_rule != newVals.join_rule && newVals.join_rule != undefined) {
deferreds.push( deferreds.push(
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.join_rules", { this.state.room.roomId, "m.room.join_rules", {
join_rule: new_join_rule, join_rule: newVals.join_rule,
}, "" }, ""
) )
); );
} }
if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) { var visibilityDeferred;
deferreds.push( if (old_history_visibility != newVals.history_visibility &&
newVals.history_visibility != undefined) {
visibilityDeferred =
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.history_visibility", { this.state.room.roomId, "m.room.history_visibility", {
history_visibility: new_history_visibility, history_visibility: newVals.history_visibility,
}, ""
);
}
if (old_guest_read != newVals.guest_read ||
old_guest_join != newVals.guest_join)
{
var guestDeferred =
MatrixClientPeg.get().setGuestAccess(this.state.room.roomId, {
allowRead: newVals.guest_read,
allowJoin: newVals.guest_join
});
if (visibilityDeferred) {
visibilityDeferred = visibilityDeferred.then(guestDeferred);
}
else {
visibilityDeferred = guestDeferred;
}
}
if (visibilityDeferred) {
deferreds.push(visibilityDeferred);
}
// setRoomMutePushRule will do nothing if there is no change
deferreds.push(
MatrixClientPeg.get().setRoomMutePushRule(
"global", this.state.room.roomId, newVals.are_notifications_muted
)
);
if (newVals.power_levels) {
deferreds.push(
MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.power_levels", newVals.power_levels, ""
)
);
}
if (newVals.alias_operations) {
var oplist = [];
for (var i = 0; i < newVals.alias_operations.length; i++) {
var alias_operation = newVals.alias_operations[i];
switch (alias_operation.type) {
case 'put':
oplist.push(
MatrixClientPeg.get().createAlias(
alias_operation.alias, this.state.room.roomId
)
);
break;
case 'delete':
oplist.push(
MatrixClientPeg.get().deleteAlias(
alias_operation.alias
)
);
break;
default:
console.log("Unknown alias operation, ignoring: " + alias_operation.type);
}
}
if (oplist.length) {
var deferred = oplist[0];
oplist.splice(1).forEach(function (f) {
deferred = deferred.then(f);
});
deferreds.push(deferred);
}
}
if (newVals.tag_operations) {
// FIXME: should probably be factored out with alias_operations above
var oplist = [];
for (var i = 0; i < newVals.tag_operations.length; i++) {
var tag_operation = newVals.tag_operations[i];
switch (tag_operation.type) {
case 'put':
oplist.push(
MatrixClientPeg.get().setRoomTag(
this.props.roomId, tag_operation.tag, {}
)
);
break;
case 'delete':
oplist.push(
MatrixClientPeg.get().deleteRoomTag(
this.props.roomId, tag_operation.tag
)
);
break;
default:
console.log("Unknown tag operation, ignoring: " + tag_operation.type);
}
}
if (oplist.length) {
var deferred = oplist[0];
oplist.splice(1).forEach(function (f) {
deferred = deferred.then(f);
});
deferreds.push(deferred);
}
}
if (old_canonical_alias !== newVals.canonical_alias) {
deferreds.push(
MatrixClientPeg.get().sendStateEvent(
this.state.room.roomId, "m.room.canonical_alias", {
alias: newVals.canonical_alias
}, "" }, ""
) )
); );
} }
if (new_power_levels) { if (newVals.color_scheme) {
deferreds.push( deferreds.push(
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().setRoomAccountData(
this.state.room.roomId, "m.room.power_levels", new_power_levels, "" this.state.room.roomId, "org.matrix.room.color_scheme", newVals.color_scheme
) )
); );
} }
if (deferreds.length) { if (deferreds.length) {
var self = this; var self = this;
q.all(deferreds).fail(function(err) { q.allSettled(deferreds).then(
function(results) {
var fails = results.filter(function(result) { return result.state !== "fulfilled" });
if (fails.length) {
fails.forEach(function(result) {
console.error(result.reason);
});
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to set state", title: "Failed to set state",
description: err.toString() description: fails.map(function(result) { return result.reason }).join("\n"),
}); });
self.refs.room_settings.resetState();
}
else {
self.setState({
editingRoomSettings: false
});
}
}).finally(function() { }).finally(function() {
self.setState({ self.setState({
uploadingRoomSettings: false, uploadingRoomSettings: false,
@ -815,8 +1168,15 @@ module.exports = React.createClass({
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn(); var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
if (lastReadEventIndex === null) return; if (lastReadEventIndex === null) return;
if (lastReadEventIndex > currentReadUpToEventIndex) { var lastReadEvent = this.state.room.timeline[lastReadEventIndex];
MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]);
// we also remember the last read receipt we sent to avoid spamming the same one at the server repeatedly
if (lastReadEventIndex > currentReadUpToEventIndex && this.last_rr_sent_event_id != lastReadEvent.getId()) {
this.last_rr_sent_event_id = lastReadEvent.getId();
MatrixClientPeg.get().sendReadReceipt(lastReadEvent).catch(() => {
// it failed, so allow retries next time the user is active
this.last_rr_sent_event_id = undefined;
});
} }
}, },
@ -847,31 +1207,32 @@ module.exports = React.createClass({
}, },
onSettingsClick: function() { onSettingsClick: function() {
this.setState({editingRoomSettings: true}); this.showSettings(true);
}, },
onSaveClick: function() { onSaveClick: function() {
this.setState({ this.setState({
editingRoomSettings: false,
uploadingRoomSettings: true, uploadingRoomSettings: true,
}); });
var new_name = this.refs.header.getRoomName(); this.uploadNewState({
var new_topic = this.refs.room_settings.getTopic(); name: this.refs.header.getRoomName(),
var new_join_rule = this.refs.room_settings.getJoinRules(); topic: this.refs.header.getTopic(),
var new_history_visibility = this.refs.room_settings.getHistoryVisibility(); join_rule: this.refs.room_settings.getJoinRules(),
var new_power_levels = this.refs.room_settings.getPowerLevels(); history_visibility: this.refs.room_settings.getHistoryVisibility(),
are_notifications_muted: this.refs.room_settings.areNotificationsMuted(),
this.uploadNewState( power_levels: this.refs.room_settings.getPowerLevels(),
new_name, alias_operations: this.refs.room_settings.getAliasOperations(),
new_topic, tag_operations: this.refs.room_settings.getTagOperations(),
new_join_rule, canonical_alias: this.refs.room_settings.getCanonicalAlias(),
new_history_visibility, guest_join: this.refs.room_settings.canGuestsJoin(),
new_power_levels guest_read: this.refs.room_settings.canGuestsRead(),
); color_scheme: this.refs.room_settings.getColorScheme(),
});
}, },
onCancelClick: function() { onCancelClick: function() {
this.updateTint();
this.setState({editingRoomSettings: false}); this.setState({editingRoomSettings: false});
}, },
@ -1019,20 +1380,32 @@ module.exports = React.createClass({
// a minimum of the height of the video element, whilst also capping it from pushing out the page // a minimum of the height of the video element, whilst also capping it from pushing out the page
// so we have to do it via JS instead. In this implementation we cap the height by putting // so we have to do it via JS instead. In this implementation we cap the height by putting
// a maxHeight on the underlying remote video tag. // a maxHeight on the underlying remote video tag.
var auxPanelMaxHeight;
if (this.refs.callView) {
// XXX: don't understand why we have to call findDOMNode here in react 0.14 - it should already be a DOM node.
var video = ReactDOM.findDOMNode(this.refs.callView.refs.video.refs.remote);
// header + footer + status + give us at least 100px of scrollback at all times. // header + footer + status + give us at least 120px of scrollback at all times.
auxPanelMaxHeight = window.innerHeight - (83 + 72 + 36 + 100); var auxPanelMaxHeight = window.innerHeight -
(83 + // height of RoomHeader
36 + // height of the status area
72 + // minimum height of the message compmoser
(this.state.editingRoomSettings ? (window.innerHeight * 0.3) : 120)); // amount of desired scrollback
// XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway
// but it's better than the video going missing entirely // but it's better than the video going missing entirely
if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50;
if (this.refs.callView) {
var video = this.refs.callView.getVideoView().getRemoteVideoElement();
video.style.maxHeight = auxPanelMaxHeight + "px"; video.style.maxHeight = auxPanelMaxHeight + "px";
} }
// we need to do this for general auxPanels too
if (this.refs.auxPanel) {
this.refs.auxPanel.style.maxHeight = auxPanelMaxHeight + "px";
}
// the above might have made the aux panel resize itself, so now
// we need to tell the gemini panel to adapt.
this.onChildResize();
}, },
onFullscreenClick: function() { onFullscreenClick: function() {
@ -1066,6 +1439,24 @@ module.exports = React.createClass({
}); });
}, },
onChildResize: function() {
// When the video or the message composer resizes, the scroll panel
// also changes size. Work around GeminiScrollBar fail by telling it
// about it. This also ensures that the scroll offset is updated.
if (this.refs.messagePanel) {
this.refs.messagePanel.forceUpdate();
}
},
showSettings: function(show) {
// XXX: this is a bit naughty; we should be doing this via props
if (show) {
this.setState({editingRoomSettings: true});
var self = this;
setTimeout(function() { self.onResize() }, 0);
}
},
render: function() { render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer'); var MessageComposer = sdk.getComponent('rooms.MessageComposer');
@ -1073,15 +1464,35 @@ module.exports = React.createClass({
var RoomSettings = sdk.getComponent("rooms.RoomSettings"); var RoomSettings = sdk.getComponent("rooms.RoomSettings");
var SearchBar = sdk.getComponent("rooms.SearchBar"); var SearchBar = sdk.getComponent("rooms.SearchBar");
var ScrollPanel = sdk.getComponent("structures.ScrollPanel"); var ScrollPanel = sdk.getComponent("structures.ScrollPanel");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar");
if (!this.state.room) { if (!this.state.room) {
if (this.props.roomId) { if (this.props.roomId) {
if (this.props.autoPeek && !this.state.autoPeekDone) {
var Loader = sdk.getComponent("elements.Spinner");
return ( return (
<div> <div className="mx_RoomView">
<button onClick={this.onJoinButtonClicked}>Join Room</button> <Loader />
</div> </div>
); );
} else { }
else {
var joinErrorText = this.state.joinError ? "Failed to join room!" : "";
return (
<div className="mx_RoomView">
<RoomHeader ref="header" room={this.state.room} simpleHeader="Join room"/>
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={ this.onJoinButtonClicked }
canJoin={ true } canPreview={ false }/>
<div className="error">{joinErrorText}</div>
</div>
<div className="mx_RoomView_messagePanel"></div>
</div>
);
}
}
else {
return ( return (
<div /> <div />
); );
@ -1102,19 +1513,26 @@ module.exports = React.createClass({
var inviteEvent = myMember.events.member; var inviteEvent = myMember.events.member;
var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender(); var inviterName = inviteEvent.sender ? inviteEvent.sender.name : inviteEvent.getSender();
// XXX: Leaving this intentionally basic for now because invites are about to change totally // XXX: Leaving this intentionally basic for now because invites are about to change totally
// FIXME: This comment is now outdated - what do we need to fix? ^
var joinErrorText = this.state.joinError ? "Failed to join room!" : ""; var joinErrorText = this.state.joinError ? "Failed to join room!" : "";
var rejectErrorText = this.state.rejectError ? "Failed to reject invite!" : ""; var rejectErrorText = this.state.rejectError ? "Failed to reject invite!" : "";
// We deliberately don't try to peek into invites, even if we have permission to peek
// as they could be a spam vector.
// XXX: in future we could give the option of a 'Preview' button which lets them view anyway.
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
<RoomHeader ref="header" room={this.state.room} simpleHeader="Room invite"/> <RoomHeader ref="header" room={this.state.room}/>
<div className="mx_RoomView_invitePrompt"> <div className="mx_RoomView_auxPanel">
<div>{inviterName} has invited you to a room</div> <RoomPreviewBar onJoinClick={ this.onJoinButtonClicked }
<br/> onRejectClick={ this.onRejectButtonClicked }
<button ref="joinButton" onClick={this.onJoinButtonClicked}>Join</button> inviterName={ inviterName }
<button onClick={this.onRejectButtonClicked}>Reject</button> canJoin={ true } canPreview={ false }/>
<div className="error">{joinErrorText}</div> <div className="error">{joinErrorText}</div>
<div className="error">{rejectErrorText}</div> <div className="error">{rejectErrorText}</div>
</div> </div>
<div className="mx_RoomView_messagePanel"></div>
</div> </div>
); );
} }
@ -1147,7 +1565,7 @@ module.exports = React.createClass({
if (this.state.syncState === "ERROR") { if (this.state.syncState === "ERROR") {
statusBar = ( statusBar = (
<div className="mx_RoomView_connectionLostBar"> <div className="mx_RoomView_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" alt="/!\ "/> <img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ "/>
<div className="mx_RoomView_connectionLostBar_textArea"> <div className="mx_RoomView_connectionLostBar_textArea">
<div className="mx_RoomView_connectionLostBar_title"> <div className="mx_RoomView_connectionLostBar_title">
Connectivity to the server has been lost. Connectivity to the server has been lost.
@ -1166,8 +1584,8 @@ module.exports = React.createClass({
<div className="mx_RoomView_tabCompleteImage">...</div> <div className="mx_RoomView_tabCompleteImage">...</div>
<div className="mx_RoomView_tabCompleteWrapper"> <div className="mx_RoomView_tabCompleteWrapper">
<TabCompleteBar entries={this.tabComplete.peek(6)} /> <TabCompleteBar entries={this.tabComplete.peek(6)} />
<div className="mx_RoomView_tabCompleteEol"> <div className="mx_RoomView_tabCompleteEol" title="->|">
<img src="img/eol.svg" width="22" height="16" alt="->|"/> <TintableSvg src="img/eol.svg" width="22" height="16"/>
Auto-complete Auto-complete
</div> </div>
</div> </div>
@ -1177,7 +1595,7 @@ module.exports = React.createClass({
else if (this.state.hasUnsentMessages) { else if (this.state.hasUnsentMessages) {
statusBar = ( statusBar = (
<div className="mx_RoomView_connectionLostBar"> <div className="mx_RoomView_connectionLostBar">
<img src="img/warning.svg" width="24" height="23" alt="/!\ "/> <img src="img/warning.svg" width="24" height="23" title="/!\ " alt="/!\ "/>
<div className="mx_RoomView_connectionLostBar_textArea"> <div className="mx_RoomView_connectionLostBar_textArea">
<div className="mx_RoomView_connectionLostBar_title"> <div className="mx_RoomView_connectionLostBar_title">
Some of your messages have not been sent. Some of your messages have not been sent.
@ -1214,7 +1632,7 @@ module.exports = React.createClass({
var aux = null; var aux = null;
if (this.state.editingRoomSettings) { if (this.state.editingRoomSettings) {
aux = <RoomSettings ref="room_settings" onSaveClick={this.onSaveClick} room={this.state.room} />; aux = <RoomSettings ref="room_settings" onSaveClick={this.onSaveClick} onCancelClick={this.onCancelClick} room={this.state.room} />;
} }
else if (this.state.uploadingRoomSettings) { else if (this.state.uploadingRoomSettings) {
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
@ -1223,6 +1641,18 @@ module.exports = React.createClass({
else if (this.state.searching) { else if (this.state.searching) {
aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress } onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch}/>; aux = <SearchBar ref="search_bar" searchInProgress={this.state.searchInProgress } onCancelClick={this.onCancelSearchClick} onSearch={this.onSearch}/>;
} }
else if (this.state.guestsCanJoin && MatrixClientPeg.get().isGuest() &&
(!myMember || myMember.membership !== "join")) {
aux = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked} canJoin={true} />
);
}
else if (this.state.canPeek &&
(!myMember || myMember.membership !== "join")) {
aux = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked} canJoin={true} />
);
}
var conferenceCallNotification = null; var conferenceCallNotification = null;
if (this.state.displayConfCallNotification) { if (this.state.displayConfCallNotification) {
@ -1240,9 +1670,9 @@ module.exports = React.createClass({
var fileDropTarget = null; var fileDropTarget = null;
if (this.state.draggingFile) { if (this.state.draggingFile) {
fileDropTarget = <div className="mx_RoomView_fileDropTarget"> fileDropTarget = <div className="mx_RoomView_fileDropTarget">
<div className="mx_RoomView_fileDropTargetLabel"> <div className="mx_RoomView_fileDropTargetLabel" title="Drop File Here">
<img src="img/upload-big.svg" width="45" height="59" alt="Drop File Here"/><br/> <TintableSvg src="img/upload-big.svg" width="45" height="59"/><br/>
Drop File Here Drop file here to upload
</div> </div>
</div>; </div>;
} }
@ -1255,7 +1685,7 @@ module.exports = React.createClass({
if (canSpeak) { if (canSpeak) {
messageComposer = messageComposer =
<MessageComposer <MessageComposer
room={this.state.room} roomView={this} uploadFile={this.uploadFile} room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile}
callState={this.state.callState} tabComplete={this.tabComplete} /> callState={this.state.callState} tabComplete={this.tabComplete} />
} }
@ -1278,25 +1708,29 @@ module.exports = React.createClass({
if (call.type === "video") { if (call.type === "video") {
zoomButton = ( zoomButton = (
<div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick}> <div className="mx_RoomView_voipButton" onClick={this.onFullscreenClick} title="Fill screen">
<img src="img/fullscreen.svg" title="Fill screen" alt="Fill screen" width="29" height="22" style={{ marginTop: 1, marginRight: 4 }}/> <TintableSvg src="img/fullscreen.svg" width="29" height="22" style={{ marginTop: 1, marginRight: 4 }}/>
</div> </div>
); );
videoMuteButton = videoMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteVideoClick}>
<img src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"} width="31" height="27"/> <img src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"}
alt={call.isLocalVideoMuted() ? "Click to unmute video" : "Click to mute video"}
width="31" height="27"/>
</div> </div>
} }
voiceMuteButton = voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<img src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"} width="21" height="26"/> <img src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"}
alt={call.isMicrophoneMuted() ? "Click to unmute audio" : "Click to mute audio"}
width="21" height="26"/>
</div> </div>
if (!statusBar) { if (!statusBar) {
statusBar = statusBar =
<div className="mx_RoomView_callBar"> <div className="mx_RoomView_callBar">
<img src="img/sound-indicator.svg" width="23" height="20" alt=""/> <img src="img/sound-indicator.svg" width="23" height="20"/>
<b>Active call</b> <b>Active call</b>
</div>; </div>;
} }
@ -1307,11 +1741,10 @@ module.exports = React.createClass({
{ videoMuteButton } { videoMuteButton }
{ zoomButton } { zoomButton }
{ statusBar } { statusBar }
<img className="mx_RoomView_voipChevron" src="img/voip-chevron.svg" width="22" height="17"/> <TintableSvg className="mx_RoomView_voipChevron" src="img/voip-chevron.svg" width="22" height="17"/>
</div> </div>
} }
// if we have search results, we keep the messagepanel (so that it preserves its // if we have search results, we keep the messagepanel (so that it preserves its
// scroll state), but hide it. // scroll state), but hide it.
var searchResultsPanel; var searchResultsPanel;
@ -1339,7 +1772,7 @@ module.exports = React.createClass({
); );
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") }> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
<RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo} <RoomHeader ref="header" room={this.state.room} searchInfo={searchInfo}
editing={this.state.editingRoomSettings} editing={this.state.editingRoomSettings}
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
@ -1352,9 +1785,10 @@ module.exports = React.createClass({
onLeaveClick={ onLeaveClick={
(myMember && myMember.membership === "join") ? this.onLeaveClick : null (myMember && myMember.membership === "join") ? this.onLeaveClick : null
} /> } />
<div className="mx_RoomView_auxPanel" ref="auxPanel">
{ fileDropTarget } { fileDropTarget }
<div className="mx_RoomView_auxPanel"> <CallView ref="callView" room={this.state.room} ConferenceHandler={this.props.ConferenceHandler}
<CallView ref="callView" room={this.state.room} ConferenceHandler={this.props.ConferenceHandler}/> onResize={this.onChildResize} />
{ conferenceCallNotification } { conferenceCallNotification }
{ aux } { aux }
</div> </div>

View file

@ -112,6 +112,14 @@ module.exports = React.createClass({
this.checkFillState(); 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) { onScroll: function(ev) {
var sn = this._getScrollNode(); var sn = this._getScrollNode();
debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll); 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. // check the scroll state and send out backfill requests if necessary.
checkFillState: function() { checkFillState: function() {
if (this.unmounted) {
return;
}
var sn = this._getScrollNode(); var sn = this._getScrollNode();
// if there is less than a screenful of messages above or below the // if there is less than a screenful of messages above or below the
@ -346,6 +358,12 @@ module.exports = React.createClass({
* message panel. * message panel.
*/ */
_getScrollNode: function() { _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); var panel = ReactDOM.findDOMNode(this.refs.geminiPanel);
// If the gemini scrollbar is doing its thing, this will be a div within // 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) { if (!upload) {
upload = uploads[0]; return <div />
} }
var innerProgressStyle = { var innerProgressStyle = {

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require('react'); var React = require('react');
var ReactDOM = require('react-dom');
var sdk = require('../../index'); var sdk = require('../../index');
var MatrixClientPeg = require("../../MatrixClientPeg"); var MatrixClientPeg = require("../../MatrixClientPeg");
var Modal = require('../../Modal'); var Modal = require('../../Modal');
@ -21,6 +22,9 @@ var dis = require("../../dispatcher");
var q = require('q'); var q = require('q');
var version = require('../../../package.json').version; var version = require('../../../package.json').version;
var UserSettingsStore = require('../../UserSettingsStore'); var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'UserSettings', displayName: 'UserSettings',
@ -41,6 +45,7 @@ module.exports = React.createClass({
threePids: [], threePids: [],
clientVersion: version, clientVersion: version,
phase: "UserSettings.LOADING", // LOADING, DISPLAY 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) { onAvatarSelected: function(ev) {
var self = this; var self = this;
var changeAvatar = this.refs.changeAvatar; var changeAvatar = this.refs.changeAvatar;
@ -135,6 +146,12 @@ module.exports = React.createClass({
}); });
}, },
onUpgradeClicked: function() {
dis.dispatch({
action: "start_upgrade_registration"
});
},
onLogoutPromptCancel: function() { onLogoutPromptCancel: function() {
this.logoutModal.closeDialog(); this.logoutModal.closeDialog();
}, },
@ -143,10 +160,81 @@ module.exports = React.createClass({
UserSettingsStore.setEnableNotifications(event.target.checked); 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() { render: function() {
var self = this;
var Loader = sdk.getComponent("elements.Spinner");
switch (this.state.phase) { switch (this.state.phase) {
case "UserSettings.LOADING": case "UserSettings.LOADING":
var Loader = sdk.getComponent("elements.Spinner");
return ( return (
<Loader /> <Loader />
); );
@ -160,14 +248,76 @@ module.exports = React.createClass({
var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName"); var ChangeDisplayName = sdk.getComponent("views.settings.ChangeDisplayName");
var ChangePassword = sdk.getComponent("views.settings.ChangePassword"); var ChangePassword = sdk.getComponent("views.settings.ChangePassword");
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var Notifications = sdk.getComponent("settings.Notifications");
var EditableText = sdk.getComponent('elements.EditableText');
var avatarUrl = ( var avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null 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 ( return (
<div className="mx_UserSettings"> <div className="mx_UserSettings">
<RoomHeader simpleHeader="Settings" /> <RoomHeader simpleHeader="Settings" />
<GeminiScrollbar className="mx_UserSettings_body" autoshow={true}>
<h2>Profile</h2> <h2>Profile</h2>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
@ -180,30 +330,19 @@ module.exports = React.createClass({
<ChangeDisplayName /> <ChangeDisplayName />
</div> </div>
</div> </div>
{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>
);
})}
</div> </div>
<div className="mx_UserSettings_avatarPicker"> <div className="mx_UserSettings_avatarPicker">
<div onClick={ this.onAvatarPickerClick }>
<ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl} <ChangeAvatar ref="changeAvatar" initialAvatarUrl={avatarUrl}
showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/> showUploadSection={false} className="mx_UserSettings_avatarPicker_img"/>
</div>
<div className="mx_UserSettings_avatarPicker_edit"> <div className="mx_UserSettings_avatarPicker_edit">
<label htmlFor="avatarInput"> <label htmlFor="avatarInput" ref="file_label">
<img src="img/upload.svg" <img src="img/camera.svg"
alt="Upload avatar" title="Upload avatar" alt="Upload avatar" title="Upload avatar"
width="19" height="24" /> width="17" height="15" />
</label> </label>
<input id="avatarInput" type="file" onChange={this.onAvatarSelected}/> <input id="avatarInput" type="file" onChange={this.onAvatarSelected}/>
</div> </div>
@ -213,41 +352,18 @@ module.exports = React.createClass({
<h2>Account</h2> <h2>Account</h2>
<div className="mx_UserSettings_section"> <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_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
<div className="mx_UserSettings_button" onClick={this.onLogoutClicked}>
Log out Log out
</div> </div>
{accountJsx}
</div> </div>
<h2>Notifications</h2> <h2>Notifications</h2>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<div className="mx_UserSettings_notifTable"> <Notifications/>
<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>
</div> </div>
<h2>Advanced</h2> <h2>Advanced</h2>
@ -260,6 +376,8 @@ module.exports = React.createClass({
Version {this.state.clientVersion} Version {this.state.clientVersion}
</div> </div>
</div> </div>
</GeminiScrollbar>
</div> </div>
); );
} }

View file

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

View file

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

View file

@ -19,7 +19,6 @@ limitations under the License.
var React = require('react'); var React = require('react');
var sdk = require('../../../index'); var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var dis = require('../../../dispatcher'); var dis = require('../../../dispatcher');
var Signup = require("../../../Signup"); var Signup = require("../../../Signup");
var ServerConfig = require("../../views/login/ServerConfig"); var ServerConfig = require("../../views/login/ServerConfig");
@ -40,6 +39,9 @@ module.exports = React.createClass({
hsUrl: React.PropTypes.string, hsUrl: React.PropTypes.string,
isUrl: React.PropTypes.string, isUrl: React.PropTypes.string,
email: 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. // registration shouldn't know or care how login is done.
onLoginClick: React.PropTypes.func.isRequired onLoginClick: React.PropTypes.func.isRequired
}, },
@ -63,6 +65,7 @@ module.exports = React.createClass({
this.registerLogic.setSessionId(this.props.sessionId); this.registerLogic.setSessionId(this.props.sessionId);
this.registerLogic.setRegistrationUrl(this.props.registrationUrl); this.registerLogic.setRegistrationUrl(this.props.registrationUrl);
this.registerLogic.setIdSid(this.props.idSid); this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
this.registerLogic.recheckState(); this.registerLogic.recheckState();
}, },
@ -156,6 +159,15 @@ module.exports = React.createClass({
case "RegistrationForm.ERR_PASSWORD_LENGTH": case "RegistrationForm.ERR_PASSWORD_LENGTH":
errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`; errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`;
break; break;
case "RegistrationForm.ERR_EMAIL_INVALID":
errMsg = "This doesn't look like a valid email address";
break;
case "RegistrationForm.ERR_USERNAME_INVALID":
errMsg = "User names may only contain letters, numbers, dots, hyphens and underscores.";
break;
case "RegistrationForm.ERR_USERNAME_BLANK":
errMsg = "You need to enter a user name";
break;
default: default:
console.error("Unknown error code: %s", errCode); console.error("Unknown error code: %s", errCode);
errMsg = "An unknown error occurred."; errMsg = "An unknown error occurred.";
@ -186,7 +198,9 @@ module.exports = React.createClass({
registerStep = ( registerStep = (
<RegistrationForm <RegistrationForm
showEmail={true} showEmail={true}
defaultUsername={this.props.username}
defaultEmail={this.props.email} defaultEmail={this.props.email}
disableUsernameChanges={this.props.disableUsernameChanges}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} /> 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 React = require('react');
var Avatar = require('../../../Avatar'); var Avatar = require('../../../Avatar');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require("../../../index");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MemberAvatar', displayName: 'MemberAvatar',
@ -27,7 +27,7 @@ module.exports = React.createClass({
member: React.PropTypes.object.isRequired, member: React.PropTypes.object.isRequired,
width: React.PropTypes.number, width: React.PropTypes.number,
height: React.PropTypes.number, height: React.PropTypes.number,
resizeMethod: React.PropTypes.string, resizeMethod: React.PropTypes.string
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -38,75 +38,30 @@ module.exports = React.createClass({
} }
}, },
componentWillReceiveProps: function(nextProps) {
this.refreshUrl();
},
defaultAvatarUrl: function(member, width, height, resizeMethod) {
return Avatar.defaultAvatarUrlForString(member.userId);
},
onError: function(ev) {
// don't tightloop if the browser can't load a data url
if (ev.target.src == this.defaultAvatarUrl(this.props.member)) {
return;
}
this.setState({
imageUrl: this.defaultAvatarUrl(this.props.member)
});
},
_computeUrl: function() {
return Avatar.avatarUrlForMember(this.props.member,
this.props.width,
this.props.height,
this.props.resizeMethod);
},
refreshUrl: function() {
var newUrl = this._computeUrl();
if (newUrl != this.currentUrl) {
this.currentUrl = newUrl;
this.setState({imageUrl: newUrl});
}
},
getInitialState: function() { getInitialState: function() {
return { return this._getState(this.props);
imageUrl: this._computeUrl()
};
}, },
componentWillReceiveProps: function(nextProps) {
this.setState(this._getState(nextProps));
},
/////////////// _getState: function(props) {
return {
name: props.member.name,
title: props.member.userId,
imageUrl: Avatar.avatarUrlForMember(props.member,
props.width,
props.height,
props.resizeMethod)
}
},
render: function() { render: function() {
// XXX: recalculates default avatar url constantly var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
if (this.state.imageUrl === this.defaultAvatarUrl(this.props.member)) {
var initial;
if (this.props.member.name[0])
initial = this.props.member.name[0].toUpperCase();
if (initial === '@' && this.props.member.name[1])
initial = this.props.member.name[1].toUpperCase();
return ( return (
<span className="mx_MemberAvatar" {...this.props}> <BaseAvatar {...this.props} name={this.state.name} title={this.state.title}
<span className="mx_MemberAvatar_initial" aria-hidden="true" idName={this.props.member.userId} url={this.state.imageUrl} />
style={{ fontSize: (this.props.width * 0.65) + "px",
width: this.props.width + "px",
lineHeight: this.props.height + "px" }}>{ initial }</span>
<img className="mx_MemberAvatar_image" src={this.state.imageUrl} title={this.props.member.name}
onError={this.onError} width={this.props.width} height={this.props.height} />
</span>
);
}
return (
<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}
/>
); );
} }
}); });

View file

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

View file

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

View file

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

View file

@ -0,0 +1,108 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
'use strict';
var React = require('react');
var roles = {
0: 'User',
50: 'Moderator',
100: 'Admin',
};
var reverseRoles = {};
Object.keys(roles).forEach(function(key) {
reverseRoles[roles[key]] = key;
});
module.exports = React.createClass({
displayName: 'PowerSelector',
propTypes: {
value: React.PropTypes.number.isRequired,
disabled: React.PropTypes.bool,
onChange: React.PropTypes.func,
},
getInitialState: function() {
return {
custom: (roles[this.props.value] === undefined),
};
},
onSelectChange: function(event) {
this.state.custom = (event.target.value === "Custom");
this.props.onChange(this.getValue());
},
onCustomBlur: function(event) {
this.props.onChange(this.getValue());
},
onCustomKeyDown: function(event) {
if (event.key == "Enter") {
this.props.onChange(this.getValue());
}
},
getValue: function() {
var value;
if (this.refs.select) {
value = reverseRoles[ this.refs.select.value ];
if (this.refs.custom) {
if (value === undefined) value = parseInt( this.refs.custom.value );
}
}
return value;
},
render: function() {
var customPicker;
if (this.state.custom) {
var input;
if (this.props.disabled) {
input = <span>{ this.props.value }</span>
}
else {
input = <input ref="custom" type="text" size="3" defaultValue={ this.props.value } onBlur={ this.onCustomBlur } onKeyDown={ this.onCustomKeyDown }/>
}
customPicker = <span> of { input }</span>;
}
var selectValue = roles[this.props.value] || "Custom";
var select;
if (this.props.disabled) {
select = <span>{ selectValue }</span>;
}
else {
select =
<select ref="select" defaultValue={ selectValue } onChange={ this.onSelectChange }>
<option value="User">User (0)</option>
<option value="Moderator">Moderator (50)</option>
<option value="Admin">Admin (100)</option>
<option value="Custom">Custom level</option>
</select>
}
return (
<span className="mx_PowerSelector">
{ select }
{ customPicker }
</span>
);
}
});

View file

@ -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() { render: function() {
return ( return (
<div className="mx_ErrorDialog"> <div className="mx_ErrorDialog">
<div className="mx_ErrorDialogTitle"> <div className="mx_Dialog_title">
Custom Server Options Custom Server Options
</div> </div>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">

View file

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

View file

@ -17,7 +17,15 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); var React = require('react');
var Velocity = require('velocity-animate');
require('velocity-ui-pack');
var sdk = require('../../../index'); var sdk = require('../../../index');
var 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. * A pure UI component which displays a registration form.
@ -30,6 +38,7 @@ module.exports = React.createClass({
defaultUsername: React.PropTypes.string, defaultUsername: React.PropTypes.string,
showEmail: React.PropTypes.bool, showEmail: React.PropTypes.bool,
minPasswordLength: React.PropTypes.number, minPasswordLength: React.PropTypes.number,
disableUsernameChanges: React.PropTypes.bool,
onError: React.PropTypes.func, onError: React.PropTypes.func,
onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise
}, },
@ -49,34 +58,28 @@ module.exports = React.createClass({
email: this.props.defaultEmail, email: this.props.defaultEmail,
username: this.props.defaultUsername, username: this.props.defaultUsername,
password: null, password: null,
passwordConfirm: null passwordConfirm: null,
fieldValid: {}
}; };
}, },
onSubmit: function(ev) { onSubmit: function(ev) {
ev.preventDefault(); ev.preventDefault();
var pwd1 = this.refs.password.value.trim(); // validate everything, in reverse order so
var pwd2 = this.refs.passwordConfirm.value.trim() // the error that ends up being displayed
// is the one from the first invalid field.
var errCode; // It's not super ideal that this just calls
if (!pwd1 || !pwd2) { // onError once for each invalid field.
errCode = "RegistrationForm.ERR_PASSWORD_MISSING"; this.validateField(FIELD_PASSWORD_CONFIRM);
} this.validateField(FIELD_PASSWORD);
else if (pwd1 !== pwd2) { this.validateField(FIELD_USERNAME);
errCode = "RegistrationForm.ERR_PASSWORD_MISMATCH"; this.validateField(FIELD_EMAIL);
}
else if (pwd1.length < this.props.minPasswordLength) {
errCode = "RegistrationForm.ERR_PASSWORD_LENGTH";
}
if (errCode) {
this.props.onError(errCode);
return;
}
if (this.allFieldsValid()) {
var promise = this.props.onRegisterClick({ var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim(), username: this.refs.username.value.trim(),
password: pwd1, password: this.refs.password.value.trim(),
email: this.refs.email.value.trim() email: this.refs.email.value.trim()
}); });
@ -86,15 +89,120 @@ module.exports = React.createClass({
ev.target.disabled = false; 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() { render: function() {
var self = this;
var emailSection, registerButton; var emailSection, registerButton;
if (this.props.showEmail) { if (this.props.showEmail) {
emailSection = ( emailSection = (
<input className="mx_Login_field" type="text" ref="email" <input className="mx_Login_field" type="text" ref="email"
autoFocus={true} placeholder="Email address" autoFocus={true} placeholder="Email address"
defaultValue={this.state.email} /> defaultValue={this.state.email}
style={this._styleField(FIELD_EMAIL)}
onBlur={function() {self.validateField(FIELD_EMAIL)}} />
); );
} }
if (this.props.onRegisterClick) { if (this.props.onRegisterClick) {
@ -109,13 +217,20 @@ module.exports = React.createClass({
{emailSection} {emailSection}
<br /> <br />
<input className="mx_Login_field" type="text" ref="username" <input className="mx_Login_field" type="text" ref="username"
placeholder="User name" defaultValue={this.state.username} /> placeholder="User name" defaultValue={this.state.username}
style={this._styleField(FIELD_USERNAME)}
onBlur={function() {self.validateField(FIELD_USERNAME)}}
disabled={this.props.disableUsernameChanges} />
<br /> <br />
<input className="mx_Login_field" type="password" ref="password" <input className="mx_Login_field" type="password" ref="password"
style={this._styleField(FIELD_PASSWORD)}
onBlur={function() {self.validateField(FIELD_PASSWORD)}}
placeholder="Password" defaultValue={this.state.password} /> placeholder="Password" defaultValue={this.state.password} />
<br /> <br />
<input className="mx_Login_field" type="password" ref="passwordConfirm" <input className="mx_Login_field" type="password" ref="passwordConfirm"
placeholder="Confirm password" placeholder="Confirm password"
style={this._styleField(FIELD_PASSWORD_CONFIRM)}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
defaultValue={this.state.passwordConfirm} /> defaultValue={this.state.passwordConfirm} />
<br /> <br />
{registerButton} {registerButton}

View file

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

View file

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

View file

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

View file

@ -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

@ -66,7 +66,8 @@ module.exports = React.createClass({
title: "Kick error", title: "Kick error",
description: err.message description: err.message
}); });
}); }
);
this.props.onFinished(); this.props.onFinished();
}, },
@ -74,7 +75,8 @@ module.exports = React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
var roomId = this.props.member.roomId; var roomId = this.props.member.roomId;
var target = this.props.member.userId; var target = this.props.member.userId;
MatrixClientPeg.get().ban(roomId, target).done(function() { MatrixClientPeg.get().ban(roomId, target).done(
function() {
// NO-OP; rely on the m.room.member event coming down else we could // NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here! // get out of sync if we force setState here!
console.log("Ban success"); console.log("Ban success");
@ -83,7 +85,8 @@ module.exports = React.createClass({
title: "Ban error", title: "Ban error",
description: err.message description: err.message
}); });
}); }
);
this.props.onFinished(); this.props.onFinished();
}, },
@ -127,7 +130,8 @@ module.exports = React.createClass({
title: "Mute error", title: "Mute error",
description: err.message description: err.message
}); });
}); }
);
this.props.onFinished(); this.props.onFinished();
}, },
@ -154,6 +158,7 @@ module.exports = React.createClass({
} }
var defaultLevel = powerLevelEvent.getContent().users_default; var defaultLevel = powerLevelEvent.getContent().users_default;
var modLevel = me.powerLevel - 1; var modLevel = me.powerLevel - 1;
if (modLevel > 50 && defaultLevel < 50) modLevel = 50; // try to stick with the vector level defaults
// toggle the level // toggle the level
var newLevel = this.state.isTargetMod ? defaultLevel : modLevel; var newLevel = this.state.isTargetMod ? defaultLevel : modLevel;
MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done( MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done(
@ -166,7 +171,39 @@ module.exports = React.createClass({
title: "Mod error", title: "Mod error",
description: err.message 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(); this.props.onFinished();
}, },
@ -209,7 +246,8 @@ module.exports = React.createClass({
MatrixClientPeg.get().createRoom({ MatrixClientPeg.get().createRoom({
invite: [this.props.member.userId], invite: [this.props.member.userId],
preset: "private_chat" preset: "private_chat"
}).done(function(res) { }).done(
function(res) {
self.setState({ creatingRoom: false }); self.setState({ creatingRoom: false });
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
@ -222,7 +260,8 @@ module.exports = React.createClass({
"Failed to create room: %s", JSON.stringify(err) "Failed to create room: %s", JSON.stringify(err)
); );
self.props.onFinished(); self.props.onFinished();
}); }
);
} }
}, },
@ -291,9 +330,15 @@ module.exports = React.createClass({
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) ||
powerLevels.state_default powerLevels.state_default
); );
var levelToSend = (
(powerLevels.events ? powerLevels.events["m.room.message"] : null) ||
powerLevels.events_default
);
can.kick = me.powerLevel >= powerLevels.kick; can.kick = me.powerLevel >= powerLevels.kick;
can.ban = me.powerLevel >= powerLevels.ban; can.ban = me.powerLevel >= powerLevels.ban;
can.mute = me.powerLevel >= editPowerLevel; can.mute = me.powerLevel >= editPowerLevel;
can.toggleMod = me.powerLevel > them.powerLevel && them.powerLevel >= levelToSend;
can.modifyLevel = me.powerLevel > them.powerLevel; can.modifyLevel = me.powerLevel > them.powerLevel;
return can; return can;
}, },
@ -317,12 +362,11 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var interactButton, kickButton, banButton, muteButton, giveModButton, spinner; var startChat, kickButton, banButton, muteButton, giveModButton, spinner;
if (this.props.member.userId === MatrixClientPeg.get().credentials.userId) { if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
interactButton = <div className="mx_MemberInfo_field" onClick={this.onLeaveClick}>Leave room</div>; // FIXME: we're referring to a vector component from react-sdk
} var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile');
else { startChat = <BottomLeftMenuTile collapsed={ false } img="img/create-big.svg" label="Start chat" onClick={ this.onChatClick }/>
interactButton = <div className="mx_MemberInfo_field" onClick={this.onChatClick}>Start chat</div>;
} }
if (this.state.creatingRoom) { if (this.state.creatingRoom) {
@ -346,36 +390,57 @@ module.exports = React.createClass({
{muteLabel} {muteLabel}
</div>; </div>;
} }
if (this.state.can.modifyLevel) { if (this.state.can.toggleMod) {
var giveOpLabel = this.state.isTargetMod ? "Revoke Mod" : "Make Mod"; var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator";
giveModButton = <div className="mx_MemberInfo_field" onClick={this.onModToggle}> giveModButton = <div className="mx_MemberInfo_field" onClick={this.onModToggle}>
{giveOpLabel} {giveOpLabel}
</div> </div>
} }
// TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet
// e.g. clicking on a linkified userid in a room
var adminTools;
if (kickButton || banButton || muteButton || giveModButton) {
adminTools =
<div>
<h3>Admin tools</h3>
<div className="mx_MemberInfo_buttons">
{muteButton}
{kickButton}
{banButton}
{giveModButton}
</div>
</div>
}
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
return ( return (
<div className="mx_MemberInfo"> <div className="mx_MemberInfo">
<img className="mx_MemberInfo_cancel" src="img/cancel.svg" width="18" height="18" onClick={this.onCancel}/> <img className="mx_MemberInfo_cancel" src="img/cancel.svg" width="18" height="18" onClick={this.onCancel}/>
<div className="mx_MemberInfo_avatar"> <div className="mx_MemberInfo_avatar">
<MemberAvatar member={this.props.member} width={48} height={48} /> <MemberAvatar member={this.props.member} width={48} height={48} />
</div> </div>
<h2>{ this.props.member.name }</h2> <h2>{ this.props.member.name }</h2>
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField"> <div className="mx_MemberInfo_profileField">
{ this.props.member.userId } { this.props.member.userId }
</div> </div>
<div className="mx_MemberInfo_profileField"> <div className="mx_MemberInfo_profileField">
power: { this.props.member.powerLevelNorm }% Level: <b><PowerSelector value={ parseInt(this.props.member.powerLevel) } disabled={ !this.state.can.modifyLevel } onChange={ this.onPowerChange }/></b>
</div> </div>
<div className="mx_MemberInfo_buttons"> </div>
{interactButton}
{muteButton} { startChat }
{kickButton}
{banButton} { adminTools }
{giveModButton}
{ spinner } { spinner }
</div> </div>
</div>
); );
} }
}); });

View file

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

View file

@ -26,25 +26,20 @@ var Modal = require("../../../Modal");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MemberTile', displayName: 'MemberTile',
propTypes: {
member: React.PropTypes.any.isRequired, // RoomMember
},
getInitialState: function() { getInitialState: function() {
return {}; return {};
}, },
onLeaveClick: function() {
dis.dispatch({
action: 'leave_room',
room_id: this.props.member.roomId,
});
this.props.onFinished();
},
shouldComponentUpdate: function(nextProps, nextState) { shouldComponentUpdate: function(nextProps, nextState) {
if (this.state.hover !== nextState.hover) return true;
if ( if (
this.member_last_modified_time === undefined || this.member_last_modified_time === undefined ||
this.member_last_modified_time < nextProps.member.getLastModifiedTime() this.member_last_modified_time < nextProps.member.getLastModifiedTime()
) { ) {
return true return true;
} }
if ( if (
nextProps.member.user && nextProps.member.user &&
@ -56,14 +51,6 @@ module.exports = React.createClass({
return false; return false;
}, },
mouseEnter: function(e) {
this.setState({ 'hover': true });
},
mouseLeave: function(e) {
this.setState({ 'hover': false });
},
onClick: function(e) { onClick: function(e) {
dis.dispatch({ dis.dispatch({
action: 'view_user', action: 'view_user',
@ -71,114 +58,43 @@ module.exports = React.createClass({
}); });
}, },
getDuration: function(time) { _getDisplayName: function() {
if (!time) return; return this.props.member.name;
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";
}, },
getPowerLabel: function() { getPowerLabel: function() {
var label = this.props.member.userId; return this.props.member.userId + " (power " + this.props.member.powerLevel + ")";
if (this.state.isTargetMod) {
label += " - Mod (" + this.props.member.powerLevelNorm + "%)";
}
return label;
}, },
render: function() { 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 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 ( return (
<div className={mainClassName} title={ this.getPowerLabel() } <EntityTile {...this.props} presenceActiveAgo={active} presenceState={presenceState}
onClick={ this.onClick } onMouseEnter={ this.mouseEnter } avatarJsx={av} title={this.getPowerLabel()} onClick={this.onClick}
onMouseLeave={ this.mouseLeave }> shouldComponentUpdate={this.shouldComponentUpdate.bind(this)}
<div className="mx_MemberTile_avatar"> name={name} powerLevel={this.props.member.powerLevel} />
<MemberAvatar member={this.props.member} width={36} height={36} />
{ power }
</div>
{ nameEl }
</div>
); );
} }
}); });

View file

@ -65,8 +65,17 @@ function mdownToHtml(mdown) {
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MessageComposer', displayName: 'MessageComposer',
statics: {
// the height we limit the composer to
MAX_HEIGHT: 100,
},
propTypes: { 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() { componentWillMount: function() {
@ -200,23 +209,18 @@ module.exports = React.createClass({
this.sentHistory.push(input); this.sentHistory.push(input);
this.onEnter(ev); this.onEnter(ev);
} }
else if (ev.keyCode === KeyCode.UP) { else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
var input = this.refs.textarea.value; var oldSelectionStart = this.refs.textarea.selectionStart;
var offset = this.refs.textarea.selectionStart || 0; // Remember the keyCode because React will recycle the synthetic event
if (ev.ctrlKey || !input.substr(0, offset).match(/\n/)) { var keyCode = ev.keyCode;
this.sentHistory.next(1); // set a callback so we can see if the cursor position changes as
ev.preventDefault(); // a result of this event. If it doesn't, we cycle history.
this.resizeInput(); setTimeout(() => {
} if (this.refs.textarea.selectionStart == oldSelectionStart) {
} this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
else if (ev.keyCode === KeyCode.DOWN) {
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(); this.resizeInput();
} }
}, 0);
} }
if (this.props.tabComplete) { if (this.props.tabComplete) {
@ -237,13 +241,15 @@ module.exports = React.createClass({
// scrollHeight is at least equal to clientHeight, so we have to // scrollHeight is at least equal to clientHeight, so we have to
// temporarily crimp clientHeight to 0 to get an accurate scrollHeight value // temporarily crimp clientHeight to 0 to get an accurate scrollHeight value
this.refs.textarea.style.height = "0px"; 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"; 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; this.oldScrollHeight = this.refs.textarea.scrollHeight;
if (this.props.onResize) {
// kick gemini-scrollbar to re-layout
this.props.onResize();
}
}, },
onKeyUp: function(ev) { onKeyUp: function(ev) {
@ -330,7 +336,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);
} }
sendMessagePromise.then(function() { sendMessagePromise.done(function() {
dis.dispatch({ dis.dispatch({
action: 'message_sent' action: 'message_sent'
}); });
@ -461,6 +467,7 @@ module.exports = React.createClass({
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'}; var uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var callButton, videoCallButton, hangupButton; var callButton, videoCallButton, hangupButton;
var call = CallHandler.getCallForRoom(this.props.room.roomId); var call = CallHandler.getCallForRoom(this.props.room.roomId);
@ -473,12 +480,12 @@ module.exports = React.createClass({
} }
else { else {
callButton = callButton =
<div className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick}> <div className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call">
<img src="img/voice.svg" alt="Voice call" title="Voice call" width="16" height="26"/> <TintableSvg src="img/voice.svg" width="16" height="26"/>
</div> </div>
videoCallButton = videoCallButton =
<div className="mx_MessageComposer_videocall" onClick={this.onCallClick}> <div className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call">
<img src="img/call.svg" alt="Video call" title="Video call" width="30" height="22"/> <TintableSvg src="img/call.svg" width="30" height="22"/>
</div> </div>
} }
@ -492,8 +499,8 @@ module.exports = React.createClass({
<div className="mx_MessageComposer_input" onClick={ this.onInputClick }> <div className="mx_MessageComposer_input" onClick={ this.onInputClick }>
<textarea ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." /> <textarea ref="textarea" rows="1" onKeyDown={this.onKeyDown} onKeyUp={this.onKeyUp} placeholder="Type a message..." />
</div> </div>
<div className="mx_MessageComposer_upload" onClick={this.onUploadClick}> <div className="mx_MessageComposer_upload" onClick={this.onUploadClick} title="Upload file">
<img src="img/upload.svg" alt="Upload file" title="Upload file" width="19" height="24"/> <TintableSvg src="img/upload.svg" width="19" height="24"/>
<input type="file" style={uploadInputStyle} ref="uploadInput" onChange={this.onUploadFileSelected} /> <input type="file" style={uploadInputStyle} ref="uploadInput" onChange={this.onUploadFileSelected} />
</div> </div>
{ hangupButton } { 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 dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix');
linkifyMatrix(linkify);
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomHeader', displayName: 'RoomHeader',
@ -41,6 +47,25 @@ module.exports = React.createClass({
}; };
}, },
componentWillReceiveProps: function(newProps) {
if (newProps.editing) {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
var name = this.props.room.currentState.getStateEvents('m.room.name', '');
this.setState({
name: name ? name.getContent().name : '',
defaultName: this.props.room.getDefaultRoomName(MatrixClientPeg.get().credentials.userId),
topic: topic ? topic.getContent().topic : '',
});
}
},
componentDidUpdate: function() {
if (this.refs.topic) {
linkifyElement(this.refs.topic, linkifyMatrix.options);
}
},
onVideoClick: function(e) { onVideoClick: function(e) {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
@ -57,25 +82,59 @@ module.exports = React.createClass({
}); });
}, },
onNameChange: function(new_name) { onNameChanged: function(value) {
if (this.props.room.name != new_name && new_name) { this.setState({ name : value });
MatrixClientPeg.get().setRoomName(this.props.room.roomId, new_name); },
onTopicChanged: function(value) {
this.setState({ topic : value });
},
onAvatarPickerClick: function(ev) {
if (this.refs.file_label) {
this.refs.file_label.click();
} }
}, },
onAvatarSelected: function(ev) {
var self = this;
var changeAvatar = this.refs.changeAvatar;
if (!changeAvatar) {
console.error("No ChangeAvatar found to upload image to!");
return;
}
changeAvatar.onFileSelected(ev).done(function() {
// dunno if the avatar changed, re-check it.
self._refreshFromServer();
}, function(err) {
var errMsg = (typeof err === "string") ? err : (err.error || "");
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Error",
description: "Failed to set avatar. " + errMsg
});
});
},
getRoomName: function() { getRoomName: function() {
return this.refs.name_edit.value; return this.state.name;
},
getTopic: function() {
return this.state.topic;
}, },
render: function() { render: function() {
var EditableText = sdk.getComponent("elements.EditableText"); var EditableText = sdk.getComponent("elements.EditableText");
var RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); var RoomAvatar = sdk.getComponent("avatars.RoomAvatar");
var ChangeAvatar = sdk.getComponent("settings.ChangeAvatar");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
var header; var header;
if (this.props.simpleHeader) { if (this.props.simpleHeader) {
var cancel; var cancel;
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
cancel = <img className="mx_RoomHeader_simpleHeaderCancel" src="img/cancel-black.png" onClick={ this.props.onCancelClick } alt="Close" width="18" height="18"/> cancel = <img className="mx_RoomHeader_simpleHeaderCancel" src="img/cancel.svg" onClick={ this.props.onCancelClick } alt="Close" width="18" height="18"/>
} }
header = header =
<div className="mx_RoomHeader_wrapper"> <div className="mx_RoomHeader_wrapper">
@ -86,27 +145,72 @@ module.exports = React.createClass({
</div> </div>
} }
else { else {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
var name = null; var name = null;
var searchStatus = null; var searchStatus = null;
var topic_el = null; var topic_el = null;
var cancel_button = null; var cancel_button = null;
var save_button = null; var save_button = null;
var settings_button = null; var settings_button = null;
var actual_name = this.props.room.currentState.getStateEvents('m.room.name', '');
if (actual_name) actual_name = actual_name.getContent().name;
if (this.props.editing) { if (this.props.editing) {
name =
<div className="mx_RoomHeader_nameEditing">
<input className="mx_RoomHeader_nameInput" type="text" defaultValue={actual_name} placeholder="Name" ref="name_edit"/>
</div>
// if (topic) topic_el = <div className="mx_RoomHeader_topic"><textarea>{ topic.getContent().topic }</textarea></div>
cancel_button = <div className="mx_RoomHeader_textButton" onClick={this.props.onCancelClick}>Cancel</div>
save_button = <div className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save Changes</div>
} else {
// <EditableText label={this.props.room.name} initialValue={actual_name} placeHolder="Name" onValueChanged={this.onNameChange} />
// calculate permissions. XXX: this should be done on mount or something, and factored out with RoomSettings
var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', '');
var events_levels = (power_levels ? power_levels.events : {}) || {};
var user_id = MatrixClientPeg.get().credentials.userId;
if (power_levels) {
power_levels = power_levels.getContent();
var default_user_level = parseInt(power_levels.users_default || 0);
var user_levels = power_levels.users || {};
var current_user_level = user_levels[user_id];
if (current_user_level == undefined) current_user_level = default_user_level;
} else {
var default_user_level = 0;
var user_levels = [];
var current_user_level = 0;
}
var state_default = parseInt((power_levels ? power_levels.state_default : 0) || 0);
var room_avatar_level = state_default;
if (events_levels['m.room.avatar'] !== undefined) {
room_avatar_level = events_levels['m.room.avatar'];
}
var can_set_room_avatar = current_user_level >= room_avatar_level;
var room_name_level = state_default;
if (events_levels['m.room.name'] !== undefined) {
room_name_level = events_levels['m.room.name'];
}
var can_set_room_name = current_user_level >= room_name_level;
var room_topic_level = state_default;
if (events_levels['m.room.topic'] !== undefined) {
room_topic_level = events_levels['m.room.topic'];
}
var can_set_room_topic = current_user_level >= room_topic_level;
var placeholderName = "Unnamed Room";
if (this.state.defaultName && this.state.defaultName !== '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; var searchStatus;
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount. // gives us a valid (possibly zero) searchCount.
@ -114,39 +218,93 @@ module.exports = React.createClass({
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;(~{ this.props.searchInfo.searchCount } results)</div>; 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 = name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}> <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 } { searchStatus }
<div className="mx_RoomHeader_settingsButton"> <div className="mx_RoomHeader_settingsButton" title="Settings">
<img src="img/settings.svg" width="12" height="12"/> <TintableSvg src="img/settings.svg" width="12" height="12"/>
</div> </div>
</div> </div>
if (topic) topic_el = <div className="mx_RoomHeader_topic" title={topic.getContent().topic}>{ topic.getContent().topic }</div>; }
if (can_set_room_topic) {
topic_el =
<EditableText
className="mx_RoomHeader_topic mx_RoomHeader_editable"
placeholderClassName="mx_RoomHeader_placeholder"
placeholder="Add a topic"
blurToCancel={ false }
onValueChanged={ this.onTopicChanged }
initialValue={ this.state.topic }/>
} else {
var topic = this.props.room.currentState.getStateEvents('m.room.topic', '');
if (topic) topic_el = <div className="mx_RoomHeader_topic" ref="topic" title={ topic.getContent().topic }>{ topic.getContent().topic }</div>;
} }
var roomAvatar = null; var roomAvatar = null;
if (this.props.room) { if (this.props.room) {
if (can_set_room_avatar) {
roomAvatar = ( roomAvatar = (
<RoomAvatar room={this.props.room} width="48" height="48" /> <div className="mx_RoomHeader_avatarPicker">
<div onClick={ this.onAvatarPickerClick }>
<ChangeAvatar ref="changeAvatar" room={this.props.room} showUploadSection={false} width={48} height={48} />
</div>
<div className="mx_RoomHeader_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label">
<img src="img/camera.svg"
alt="Upload avatar" title="Upload avatar"
width="17" height="15" />
</label>
<input id="avatarInput" type="file" onChange={ this.onAvatarSelected }/>
</div>
</div>
); );
} }
else {
roomAvatar = (
<div onClick={this.props.onSettingsClick}>
<RoomAvatar room={this.props.room} width={48} height={48}/>
</div>
);
}
}
var leave_button; var leave_button;
if (this.props.onLeaveClick) { if (this.props.onLeaveClick) {
leave_button = leave_button =
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton"> <div className="mx_RoomHeader_button mx_RoomHeader_leaveButton" onClick={this.props.onLeaveClick} title="Leave room">
<img src="img/leave.svg" title="Leave room" alt="Leave room" <TintableSvg src="img/leave.svg" width="26" height="20"/>
width="26" height="20" onClick={this.props.onLeaveClick}/>
</div>; </div>;
} }
var forget_button; var forget_button;
if (this.props.onForgetClick) { if (this.props.onForgetClick) {
forget_button = forget_button =
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton"> <div className="mx_RoomHeader_button mx_RoomHeader_leaveButton" onClick={this.props.onForgetClick} title="Forget room">
<img src="img/leave.svg" title="Forget room" alt="Forget room" <TintableSvg src="img/leave.svg" width="26" height="20"/>
width="26" height="20" onClick={this.props.onForgetClick}/> </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>; </div>;
} }
@ -161,20 +319,14 @@ module.exports = React.createClass({
{ topic_el } { topic_el }
</div> </div>
</div> </div>
{cancel_button}
{save_button} {save_button}
<div className="mx_RoomHeader_rightRow"> {cancel_button}
{ forget_button } {right_row}
{ leave_button }
<div className="mx_RoomHeader_button">
<img src="img/search.svg" title="Search" alt="Search" width="21" height="19" onClick={this.props.onSearchClick}/>
</div>
</div>
</div> </div>
} }
return ( return (
<div className="mx_RoomHeader"> <div className={ "mx_RoomHeader " + (this.props.editing ? "mx_RoomHeader_editing" : "") }>
{ header } { header }
</div> </div>
); );

View file

@ -0,0 +1,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 React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var Tinter = require('../../../Tinter');
var sdk = require('../../../index'); var sdk = require('../../../index');
var Modal = require('../../../Modal');
var room_colors = [
// magic room default values courtesy of Ribot
["#76cfa6", "#eaf5f0"],
["#81bddb", "#eaf1f4"],
["#bd79cb", "#f3eaf5"],
["#c65d94", "#f5eaef"],
["#e55e5e", "#f5eaea"],
["#eca46f", "#f5eeea"],
["#dad658", "#f5f4ea"],
["#80c553", "#eef5ea"],
["#bb814e", "#eee8e3"],
["#595959", "#ececec"],
];
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomSettings', displayName: 'RoomSettings',
propTypes: { propTypes: {
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
onSaveClick: React.PropTypes.func,
onCancelClick: React.PropTypes.func,
}, },
getInitialState: function() { getInitialState: function() {
// work out the initial color index
var room_color_index = undefined;
var color_scheme_event = this.props.room.getAccountData("org.matrix.room.color_scheme");
if (color_scheme_event) {
var color_scheme = color_scheme_event.getContent();
if (color_scheme.primary_color) color_scheme.primary_color = color_scheme.primary_color.toLowerCase();
if (color_scheme.secondary_color) color_scheme.secondary_color = color_scheme.secondary_color.toLowerCase();
// XXX: we should validate these values
for (var i = 0; i < room_colors.length; i++) {
var room_color = room_colors[i];
if (room_color[0] === color_scheme.primary_color &&
room_color[1] === color_scheme.secondary_color)
{
room_color_index = i;
break;
}
}
if (room_color_index === undefined) {
// append the unrecognised colours to our palette
room_color_index = room_colors.length;
room_colors[room_color_index] = [ color_scheme.primary_color, color_scheme.secondary_color ];
}
}
else {
room_color_index = 0;
}
// get the aliases
var aliases = {};
var domain = MatrixClientPeg.get().getDomain();
var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases');
for (var i = 0; i < alias_events.length; i++) {
aliases[alias_events[i].getStateKey()] = alias_events[i].getContent().aliases.slice(); // shallow copy
}
aliases[domain] = aliases[domain] || [];
var tags = {};
Object.keys(this.props.room.tags).forEach(function(tagName) {
tags[tagName] = {};
});
return { return {
power_levels_changed: false power_levels_changed: false,
color_scheme_changed: false,
color_scheme_index: room_color_index,
aliases_changed: false,
aliases: aliases,
tags_changed: false,
tags: tags,
}; };
}, },
resetState: function() {
this.set.state(this.getInitialState());
},
canGuestsJoin: function() {
return this.refs.guests_join.checked;
},
canGuestsRead: function() {
return this.refs.guests_read.checked;
},
getTopic: function() { getTopic: function() {
return this.refs.topic.value; return this.refs.topic ? this.refs.topic.value : "";
}, },
getJoinRules: function() { getJoinRules: function() {
@ -43,6 +120,10 @@ module.exports = React.createClass({
return this.refs.share_history.checked ? "shared" : "invited"; return this.refs.share_history.checked ? "shared" : "invited";
}, },
areNotificationsMuted: function() {
return this.refs.are_notifications_muted.checked;
},
getPowerLevels: function() { getPowerLevels: function() {
if (!this.state.power_levels_changed) return undefined; if (!this.state.power_levels_changed) return undefined;
@ -50,13 +131,13 @@ module.exports = React.createClass({
power_levels = power_levels.getContent(); power_levels = power_levels.getContent();
var new_power_levels = { var new_power_levels = {
ban: parseInt(this.refs.ban.value), ban: parseInt(this.refs.ban.getValue()),
kick: parseInt(this.refs.kick.value), kick: parseInt(this.refs.kick.getValue()),
redact: parseInt(this.refs.redact.value), redact: parseInt(this.refs.redact.getValue()),
invite: parseInt(this.refs.invite.value), invite: parseInt(this.refs.invite.getValue()),
events_default: parseInt(this.refs.events_default.value), events_default: parseInt(this.refs.events_default.getValue()),
state_default: parseInt(this.refs.state_default.value), state_default: parseInt(this.refs.state_default.getValue()),
users_default: parseInt(this.refs.users_default.value), users_default: parseInt(this.refs.users_default.getValue()),
users: power_levels.users, users: power_levels.users,
events: power_levels.events, events: power_levels.events,
}; };
@ -64,17 +145,231 @@ module.exports = React.createClass({
return new_power_levels; return new_power_levels;
}, },
getCanonicalAlias: function() {
return this.refs.canonical_alias ? this.refs.canonical_alias.value : "";
},
getAliasOperations: function() {
if (!this.state.aliases_changed) return undefined;
// work out the delta from room state to UI state
var ops = [];
// calculate original ("old") aliases
var oldAliases = {};
var aliases = this.state.aliases;
var alias_events = this.props.room.currentState.getStateEvents('m.room.aliases');
for (var i = 0; i < alias_events.length; i++) {
var domain = alias_events[i].getStateKey();
oldAliases[domain] = alias_events[i].getContent().aliases.slice(); // shallow copy
}
// FIXME: this whole delta-based set comparison function used for domains, aliases & tags
// should be factored out asap rather than duplicated like this.
// work out whether any domains have entirely disappeared or appeared
var domainDelta = {}
Object.keys(oldAliases).forEach(function(domain) {
domainDelta[domain] = domainDelta[domain] || 0;
domainDelta[domain]--;
});
Object.keys(aliases).forEach(function(domain) {
domainDelta[domain] = domainDelta[domain] || 0;
domainDelta[domain]++;
});
Object.keys(domainDelta).forEach(function(domain) {
switch (domainDelta[domain]) {
case 1: // entirely new domain
aliases[domain].forEach(function(alias) {
ops.push({ type: "put", alias : alias });
});
break;
case -1: // entirely removed domain
oldAliases[domain].forEach(function(alias) {
ops.push({ type: "delete", alias : alias });
});
break;
case 0: // mix of aliases in this domain.
// compare old & new aliases for this domain
var delta = {};
oldAliases[domain].forEach(function(item) {
delta[item] = delta[item] || 0;
delta[item]--;
});
aliases[domain].forEach(function(item) {
delta[item] = delta[item] || 0;
delta[item]++;
});
Object.keys(delta).forEach(function(alias) {
if (delta[alias] == 1) {
ops.push({ type: "put", alias: alias });
} else if (delta[alias] == -1) {
ops.push({ type: "delete", alias: alias });
} else {
console.error("Calculated alias delta of " + delta[alias] +
" - this should never happen!");
}
});
break;
default:
console.error("Calculated domain delta of " + domainDelta[domain] +
" - this should never happen!");
break;
}
});
return ops;
},
getTagOperations: function() {
if (!this.state.tags_changed) return undefined;
var ops = [];
var delta = {};
Object.keys(this.props.room.tags).forEach(function(oldTag) {
delta[oldTag] = delta[oldTag] || 0;
delta[oldTag]--;
});
Object.keys(this.state.tags).forEach(function(newTag) {
delta[newTag] = delta[newTag] || 0;
delta[newTag]++;
});
Object.keys(delta).forEach(function(tag) {
if (delta[tag] == 1) {
ops.push({ type: "put", tag: tag });
} else if (delta[tag] == -1) {
ops.push({ type: "delete", tag: tag });
} else {
console.error("Calculated tag delta of " + delta[tag] +
" - this should never happen!");
}
});
return ops;
},
onPowerLevelsChanged: function() { onPowerLevelsChanged: function() {
this.setState({ this.setState({
power_levels_changed: true power_levels_changed: true
}); });
}, },
render: function() { getColorScheme: function() {
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); if (!this.state.color_scheme_changed) return undefined;
var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); return {
if (topic) topic = topic.getContent().topic; primary_color: room_colors[this.state.color_scheme_index][0],
secondary_color: room_colors[this.state.color_scheme_index][1],
};
},
onColorSchemeChanged: function(index) {
// preview what the user just changed the scheme to.
Tinter.tint(room_colors[index][0], room_colors[index][1]);
this.setState({
color_scheme_changed: true,
color_scheme_index: index,
});
},
onAliasChanged: function(domain, index, alias) {
if (alias === "") return; // hit the delete button to delete please
var oldAlias;
if (this.isAliasValid(alias)) {
oldAlias = this.state.aliases[domain][index];
this.state.aliases[domain][index] = alias;
this.setState({ aliases_changed : true });
}
else {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Invalid 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', ''); var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', '');
if (join_rule) join_rule = join_rule.getContent().join_rule; if (join_rule) join_rule = join_rule.getContent().join_rule;
@ -83,8 +378,23 @@ module.exports = React.createClass({
if (history_visibility) history_visibility = history_visibility.getContent().history_visibility; if (history_visibility) history_visibility = history_visibility.getContent().history_visibility;
var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); 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) { if (power_levels) {
power_levels = power_levels.getContent(); power_levels = power_levels.getContent();
@ -103,8 +413,6 @@ module.exports = React.createClass({
var user_levels = power_levels.users || {}; var user_levels = power_levels.users || {};
var user_id = MatrixClientPeg.get().credentials.userId;
var current_user_level = user_levels[user_id]; var current_user_level = user_levels[user_id];
if (current_user_level == undefined) current_user_level = default_user_level; if (current_user_level == undefined) current_user_level = default_user_level;
@ -133,104 +441,299 @@ module.exports = React.createClass({
var can_change_levels = false; var can_change_levels = false;
} }
var room_avatar_level = parseInt(power_levels.state_default || 0); var state_default = (parseInt(power_levels ? power_levels.state_default : 0) || 0);
if (events_levels['m.room.avatar'] !== undefined) {
room_avatar_level = events_levels['m.room.avatar']; var room_aliases_level = state_default;
} if (events_levels['m.room.aliases'] !== undefined) {
var can_set_room_avatar = current_user_level >= room_avatar_level; 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>; </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 = this.props.room.getMembersWithMembership("ban");
var banned_users_section;
return ( if (banned.length) {
<div className="mx_RoomSettings"> banned_users_section =
<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> <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>
</div>
<h3>User levels</h3>
<div className="mx_RoomSettings_user_levels mx_RoomSettings_settings">
{Object.keys(user_levels).map(function(user, i) {
return (
<div key={user}>
<label htmlFor={"mx_RoomSettings_user_"+i}>{user}</label>
<input type="text" defaultValue={user_levels[user]} size="3" id={"mx_RoomSettings_user_"+i} disabled/>
</div>
);
})}
</div>
<h3>Event levels</h3>
<div className="mx_RoomSettings_event_lvels mx_RoomSettings_settings">
{Object.keys(events_levels).map(function(event_type, i) {
return (
<div key={event_type}>
<label htmlFor={"mx_RoomSettings_event_"+i}>{event_type}</label>
<input type="text" defaultValue={events_levels[event_type]} size="3" id={"mx_RoomSettings_event_"+i} disabled/>
</div>
);
})}
</div>
<h3>Banned users</h3> <h3>Banned users</h3>
<div className="mx_RoomSettings_banned"> <ul className="mx_RoomSettings_banned">
{banned.map(function(member, i) { {banned.map(function(member, i) {
return ( return (
<div key={i}> <li key={i}>
{member.userId} {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">
{ 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>
{ 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 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> </div>
); );
})} })}
{ unfederatable_section }
</div> </div>
{change_avatar}
{ 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>
</div> </div>
); );
} }

View file

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

View file

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

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

View file

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

View file

@ -33,6 +33,12 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'CallView', 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() { componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
if (this.props.room) { if (this.props.room) {
@ -97,7 +103,7 @@ module.exports = React.createClass({
render: function(){ render: function(){
var VideoView = sdk.getComponent('voip.VideoView'); var VideoView = sdk.getComponent('voip.VideoView');
return ( 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({ module.exports = React.createClass({
displayName: 'VideoFeed', 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() { render: function() {
return ( return (
<video> <video ref="vid">
</video> </video>
); );
}, },

View file

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