diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000000..c4c7fe5067
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+src/component-index.js
diff --git a/.eslintrc.js b/.eslintrc.js
index e41106d695..d5684e21a7 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,6 +1,15 @@
+const path = require('path');
+
+// get the path of the js-sdk so we can extend the config
+// eslint supports loading extended configs by module,
+// but only if they come from a module that starts with eslint-config-
+// So we load the filename directly (and it could be in node_modules/
+// or or ../node_modules/ etc)
+const matrixJsSdkPath = path.dirname(require.resolve('matrix-js-sdk'));
+
module.exports = {
parser: "babel-eslint",
- extends: ["./node_modules/matrix-js-sdk/.eslintrc.js"],
+ extends: [matrixJsSdkPath + "/.eslintrc.js"],
plugins: [
"react",
"flowtype",
diff --git a/jenkins.sh b/jenkins.sh
index 3b4e31fd7f..c1fba19e94 100755
--- a/jenkins.sh
+++ b/jenkins.sh
@@ -19,7 +19,7 @@ npm install
npm run test
# run eslint
-npm run lint -- -f checkstyle -o eslint.xml || true
+npm run lintall -- -f checkstyle -o eslint.xml || true
# delete the old tarball, if it exists
rm -f matrix-react-sdk-*.tgz
diff --git a/package.json b/package.json
index 8e1ead4b9e..dabac0a060 100644
--- a/package.json
+++ b/package.json
@@ -10,6 +10,7 @@
"license": "Apache-2.0",
"main": "lib/index.js",
"files": [
+ ".eslintrc.js",
"CHANGELOG.md",
"CONTRIBUTING.rst",
"LICENSE",
@@ -46,10 +47,12 @@
"browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3",
"classnames": "^2.1.2",
+ "commonmark": "^0.27.0",
"draft-js": "^0.8.1",
"draft-js-export-html": "^0.5.0",
"draft-js-export-markdown": "^0.2.0",
"emojione": "2.2.3",
+ "file-saver": "^1.3.3",
"filesize": "^3.1.2",
"flux": "^2.0.3",
"fuse.js": "^2.2.0",
@@ -58,7 +61,6 @@
"isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3",
"lodash": "^4.13.1",
- "commonmark": "^0.27.0",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1",
"q": "^1.4.1",
diff --git a/src/Invite.js b/src/Invite.js
index 6ad929e33b..d1f03fe211 100644
--- a/src/Invite.js
+++ b/src/Invite.js
@@ -19,9 +19,12 @@ import MultiInviter from './utils/MultiInviter';
const emailRegex = /^\S+@\S+\.\S+$/;
+// We allow localhost for mxids to avoid confusion
+const mxidRegex = /^@\S+:(?:\S+\.\S+|localhost)$/
+
export function getAddressType(inputText) {
- const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText);
- const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0;
+ const isEmailAddress = emailRegex.test(inputText);
+ const isMatrixId = mxidRegex.test(inputText);
// sanity check the input for user IDs
if (isEmailAddress) {
diff --git a/src/KeyCode.js b/src/KeyCode.js
index bbe1ddcefa..c9cac01239 100644
--- a/src/KeyCode.js
+++ b/src/KeyCode.js
@@ -20,6 +20,7 @@ module.exports = {
TAB: 9,
ENTER: 13,
SHIFT: 16,
+ ESCAPE: 27,
PAGE_UP: 33,
PAGE_DOWN: 34,
END: 35,
diff --git a/src/Modal.js b/src/Modal.js
index 862e4befc5..f0ab97a91e 100644
--- a/src/Modal.js
+++ b/src/Modal.js
@@ -67,6 +67,8 @@ const AsyncWrapper = React.createClass({
},
});
+let _counter = 0;
+
module.exports = {
DialogContainerId: "mx_Dialog_Container",
@@ -113,12 +115,16 @@ module.exports = {
ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
};
+ // don't attempt to reuse the same AsyncWrapper for different dialogs,
+ // otherwise we'll get confused.
+ const modalCount = _counter++;
+
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the dialog from a button click!
var dialog = (
-
+
diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js
index 8c3838d615..96e76d618b 100644
--- a/src/WhoIsTyping.js
+++ b/src/WhoIsTyping.js
@@ -32,17 +32,24 @@ module.exports = {
return whoIsTyping;
},
- whoIsTypingString: function(room) {
- var whoIsTyping = this.usersTypingApartFromMe(room);
+ whoIsTypingString: function(room, limit) {
+ const whoIsTyping = this.usersTypingApartFromMe(room);
+ const othersCount = limit === undefined ?
+ 0 : Math.max(whoIsTyping.length - limit, 0);
if (whoIsTyping.length == 0) {
- return null;
+ return '';
} else if (whoIsTyping.length == 1) {
return whoIsTyping[0].name + ' is typing';
+ }
+ const names = whoIsTyping.map(function(m) {
+ return m.name;
+ });
+ if (othersCount) {
+ const other = ' other' + (othersCount > 1 ? 's' : '');
+ return names.slice(0, limit).join(', ') + ' and ' +
+ othersCount + other + ' are typing';
} else {
- var names = whoIsTyping.map(function(m) {
- return m.name;
- });
- var lastPerson = names.shift();
+ const lastPerson = names.pop();
return names.join(', ') + ' and ' + lastPerson + ' are typing';
}
}
diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js
index 284d299f4b..816b8eb73d 100644
--- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js
+++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js
@@ -14,71 +14,158 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import FileSaver from 'file-saver';
import React from 'react';
+import * as Matrix from 'matrix-js-sdk';
+import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
import sdk from '../../../index';
-import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
+const PHASE_EDIT = 1;
+const PHASE_EXPORTING = 2;
export default React.createClass({
displayName: 'ExportE2eKeysDialog',
+ propTypes: {
+ matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired,
+ onFinished: React.PropTypes.func.isRequired,
+ },
+
getInitialState: function() {
return {
- collectedPassword: false,
+ phase: PHASE_EDIT,
+ errStr: null,
};
},
+ componentWillMount: function() {
+ this._unmounted = false;
+ },
+
+ componentWillUnmount: function() {
+ this._unmounted = true;
+ },
+
_onPassphraseFormSubmit: function(ev) {
ev.preventDefault();
- console.log(this.refs.passphrase1.value);
+
+ const passphrase = this.refs.passphrase1.value;
+ if (passphrase !== this.refs.passphrase2.value) {
+ this.setState({errStr: 'Passphrases must match'});
+ return false;
+ }
+ if (!passphrase) {
+ this.setState({errStr: 'Passphrase must not be empty'});
+ return false;
+ }
+
+ this._startExport(passphrase);
return false;
},
- render: function() {
- let content;
- if (!this.state.collectedPassword) {
- content = (
-
-
- This process will allow you to export the keys for messages
- you have received in encrypted rooms to a local file. You
- will then be able to import the file into another Matrix
- client in the future, so that client will also be able to
- decrypt these messages.
-
-
- The exported file will allow anyone who can read it to decrypt
- any encrypted messages that you can see, so you should be
- careful to keep it secure. To help with this, you should enter
- a passphrase below, which will be used to encrypt the exported
- data. It will only be possible to import the data by using the
- same passphrase.
-
{ statusBar }
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js
index 4a1332be8c..1e060ae7ff 100644
--- a/src/components/structures/UserSettings.js
+++ b/src/components/structures/UserSettings.js
@@ -26,6 +26,7 @@ var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email');
var AddThreepid = require('../../AddThreepid');
+import AccessibleButton from '../views/elements/AccessibleButton';
// if this looks like a release, use the 'version' from package.json; else use
// the git sha.
@@ -228,8 +229,26 @@ module.exports = React.createClass({
},
onLogoutClicked: function(ev) {
- var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt');
- this.logoutModal = Modal.createDialog(LogoutPrompt);
+ var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+ Modal.createDialog(QuestionDialog, {
+ title: "Sign out?",
+ description:
+
+ For security, logging out will delete any end-to-end encryption keys from this browser,
+ making previous encrypted chat history unreadable if you log back in.
+ In future this will be improved,
+ but for now be warned.
+
;
},
@@ -553,10 +615,10 @@ module.exports = React.createClass({
// bind() the invited rooms so any new invites that may come in as this button is clicked
// don't inadvertently get rejected as well.
reject = (
-
+
);
}
@@ -724,9 +786,9 @@ module.exports = React.createClass({
+ Resetting password will currently reset any end-to-end encryption keys on all devices,
+ making encrypted chat history unreadable.
+ In future this may be improved,
+ but for now be warned.
+
,
+ button: "Continue",
+ onFinished: (confirmed) => {
+ if (confirmed) {
+ this.submitPasswordReset(
+ this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl,
+ this.state.email, this.state.password
+ );
+ }
+ },
+ });
}
},
diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js
index 363f340fad..c9c84aa1bf 100644
--- a/src/components/views/avatars/BaseAvatar.js
+++ b/src/components/views/avatars/BaseAvatar.js
@@ -19,6 +19,7 @@ limitations under the License.
var React = require('react');
var AvatarLogic = require("../../../Avatar");
import sdk from '../../../index';
+import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'BaseAvatar',
@@ -138,7 +139,7 @@ module.exports = React.createClass({
const {
name, idName, title, url, urls, width, height, resizeMethod,
- defaultToInitialLetter,
+ defaultToInitialLetter, onClick,
...otherProps
} = this.props;
@@ -156,12 +157,24 @@ module.exports = React.createClass({
);
}
- return (
-
- );
+ if (onClick != null) {
+ return (
+
+
+
+ );
+ } else {
+ return (
+
+ );
+ }
}
});
diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js
new file mode 100644
index 0000000000..2b3980c536
--- /dev/null
+++ b/src/components/views/dialogs/BaseDialog.js
@@ -0,0 +1,72 @@
+/*
+Copyright 2017 Vector Creations 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.
+*/
+
+import React from 'react';
+
+import * as KeyCode from '../../../KeyCode';
+
+/**
+ * Basic container for modal dialogs.
+ *
+ * Includes a div for the title, and a keypress handler which cancels the
+ * dialog on escape.
+ */
+export default React.createClass({
+ displayName: 'BaseDialog',
+
+ propTypes: {
+ // onFinished callback to call when Escape is pressed
+ onFinished: React.PropTypes.func.isRequired,
+
+ // callback to call when Enter is pressed
+ onEnterPressed: React.PropTypes.func,
+
+ // CSS class to apply to dialog div
+ className: React.PropTypes.string,
+
+ // Title for the dialog.
+ // (could probably actually be something more complicated than a string if desired)
+ title: React.PropTypes.string.isRequired,
+
+ // children should be the content of the dialog
+ children: React.PropTypes.node,
+ },
+
+ _onKeyDown: function(e) {
+ if (e.keyCode === KeyCode.ESCAPE) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.props.onFinished();
+ } else if (e.keyCode === KeyCode.ENTER) {
+ if (this.props.onEnterPressed) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.props.onEnterPressed(e);
+ }
+ }
+ },
+
+ render: function() {
+ return (
+
+
+ { this.props.title }
+
+ { this.props.children }
+
+ );
+ },
+});
diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js
index 64f90c1a30..61503196e5 100644
--- a/src/components/views/dialogs/ChatInviteDialog.js
+++ b/src/components/views/dialogs/ChatInviteDialog.js
@@ -24,9 +24,19 @@ var DMRoomMap = require('../../../utils/DMRoomMap');
var rate_limited_func = require("../../../ratelimitedfunc");
var dis = require("../../../dispatcher");
var Modal = require('../../../Modal');
+import AccessibleButton from '../elements/AccessibleButton';
const TRUNCATE_QUERY_LIST = 40;
+/*
+ * Escapes a string so it can be used in a RegExp
+ * Basically just replaces: \ ^ $ * + ? . ( ) | { } [ ]
+ * From http://stackoverflow.com/a/6969486
+ */
+function escapeRegExp(str) {
+ return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
+}
+
module.exports = React.createClass({
displayName: "ChatInviteDialog",
propTypes: {
@@ -57,7 +67,14 @@ module.exports = React.createClass({
getInitialState: function() {
return {
error: false,
+
+ // List of AddressTile.InviteAddressType objects represeting
+ // the list of addresses we're going to invite
inviteList: [],
+
+ // List of AddressTile.InviteAddressType objects represeting
+ // the set of autocompletion results for the current search
+ // query.
queryList: [],
};
},
@@ -146,14 +163,38 @@ module.exports = React.createClass({
},
onQueryChanged: function(ev) {
- var query = ev.target.value;
- var queryList = [];
+ const query = ev.target.value;
+ let queryList = [];
// Only do search if there is something to search
- if (query.length > 0) {
+ if (query.length > 0 && query != '@') {
+ // filter the known users list
queryList = this._userList.filter((user) => {
return this._matches(query, user);
+ }).map((user) => {
+ // Return objects, structure of which is defined
+ // by InviteAddressType
+ return {
+ addressType: 'mx',
+ address: user.userId,
+ displayName: user.displayName,
+ avatarMxc: user.avatarUrl,
+ isKnown: true,
+ }
});
+
+ // If the query isn't a user we know about, but is a
+ // valid address, add an entry for that
+ if (queryList.length == 0) {
+ const addrType = Invite.getAddressType(query);
+ if (addrType !== null) {
+ queryList.push({
+ addressType: addrType,
+ address: query,
+ isKnown: false,
+ });
+ }
+ }
}
this.setState({
@@ -183,7 +224,7 @@ module.exports = React.createClass({
onSelected: function(index) {
var inviteList = this.state.inviteList.slice();
- inviteList.push(this.state.queryList[index].userId);
+ inviteList.push(this.state.queryList[index]);
this.setState({
inviteList: inviteList,
queryList: [],
@@ -218,10 +259,14 @@ module.exports = React.createClass({
return;
}
+ const addrTexts = addrs.map((addr) => {
+ return addr.address;
+ });
+
if (this.props.roomId) {
// Invite new user to a room
var self = this;
- Invite.inviteMultipleToRoom(this.props.roomId, addrs)
+ Invite.inviteMultipleToRoom(this.props.roomId, addrTexts)
.then(function(addrs) {
var room = MatrixClientPeg.get().getRoom(self.props.roomId);
return self._showAnyInviteErrors(addrs, room);
@@ -236,9 +281,9 @@ module.exports = React.createClass({
return null;
})
.done();
- } else if (this._isDmChat(addrs)) {
+ } else if (this._isDmChat(addrTexts)) {
// Start the DM chat
- createRoom({dmUserId: addrs[0]})
+ createRoom({dmUserId: addrTexts[0]})
.catch(function(err) {
console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@@ -255,7 +300,7 @@ module.exports = React.createClass({
var room;
createRoom().then(function(roomId) {
room = MatrixClientPeg.get().getRoom(roomId);
- return Invite.inviteMultipleToRoom(roomId, addrs);
+ return Invite.inviteMultipleToRoom(roomId, addrTexts);
})
.then(function(addrs) {
return self._showAnyInviteErrors(addrs, room);
@@ -273,7 +318,7 @@ module.exports = React.createClass({
}
// Close - this will happen before the above, as that is async
- this.props.onFinished(true, addrs);
+ this.props.onFinished(true, addrTexts);
},
_updateUserList: new rate_limited_func(function() {
@@ -307,19 +352,27 @@ module.exports = React.createClass({
return true;
}
- // split spaces in name and try matching constituent parts
- var parts = name.split(" ");
- for (var i = 0; i < parts.length; i++) {
- if (parts[i].indexOf(query) === 0) {
- return true;
- }
+ // Try to find the query following a "word boundary", except that
+ // this does avoids using \b because it only considers letters from
+ // the roman alphabet to be word characters.
+ // Instead, we look for the query following either:
+ // * The start of the string
+ // * Whitespace, or
+ // * A fixed number of punctuation characters
+ const expr = new RegExp("(?:^|[\\s\\(\)'\",\.-_@\?;:{}\\[\\]\\#~`\\*\\&\\$])" + escapeRegExp(query));
+ if (expr.test(name)) {
+ return true;
}
+
return false;
},
_isOnInviteList: function(uid) {
for (let i = 0; i < this.state.inviteList.length; i++) {
- if (this.state.inviteList[i].toLowerCase() === uid) {
+ if (
+ this.state.inviteList[i].addressType == 'mx' &&
+ this.state.inviteList[i].address.toLowerCase() === uid
+ ) {
return true;
}
}
@@ -354,24 +407,37 @@ module.exports = React.createClass({
},
_addInputToList: function() {
- const addrType = Invite.getAddressType(this.refs.textinput.value);
- if (addrType !== null) {
- const inviteList = this.state.inviteList.slice();
- inviteList.push(this.refs.textinput.value.trim());
- this.setState({
- inviteList: inviteList,
- queryList: [],
- });
- return inviteList;
- } else {
+ const addressText = this.refs.textinput.value.trim();
+ const addrType = Invite.getAddressType(addressText);
+ const addrObj = {
+ addressType: addrType,
+ address: addressText,
+ isKnown: false,
+ };
+ if (addrType == null) {
this.setState({ error: true });
return null;
+ } else if (addrType == 'mx') {
+ const user = MatrixClientPeg.get().getUser(addrObj.address);
+ if (user) {
+ addrObj.displayName = user.displayName;
+ addrObj.avatarMxc = user.avatarUrl;
+ addrObj.isKnown = true;
+ }
}
+
+ const inviteList = this.state.inviteList.slice();
+ inviteList.push(addrObj);
+ this.setState({
+ inviteList: inviteList,
+ queryList: [],
+ });
+ return inviteList;
},
render: function() {
- var TintableSvg = sdk.getComponent("elements.TintableSvg");
- var AddressSelector = sdk.getComponent("elements.AddressSelector");
+ const TintableSvg = sdk.getComponent("elements.TintableSvg");
+ const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null;
var query = [];
@@ -404,11 +470,16 @@ module.exports = React.createClass({
if (this.state.error) {
error =
You have entered an invalid contact. Try using their Matrix ID or email address.
+
);
},
});
diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js
deleted file mode 100644
index c4bd7a0474..0000000000
--- a/src/components/views/dialogs/LogoutPrompt.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
-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 dis = require("../../../dispatcher");
-
-module.exports = React.createClass({
- displayName: 'LogoutPrompt',
-
- propTypes: {
- onFinished: React.PropTypes.func,
- },
-
- logOut: function() {
- dis.dispatch({action: 'logout'});
- if (this.props.onFinished) {
- this.props.onFinished();
- }
- },
-
- cancelPrompt: function() {
- if (this.props.onFinished) {
- this.props.onFinished();
- }
- },
-
- onKeyDown: function(e) {
- if (e.keyCode === 27) { // escape
- e.stopPropagation();
- e.preventDefault();
- this.cancelPrompt();
- }
- },
-
- render: function() {
- return (
-
+
);
- }
+ },
});
diff --git a/src/components/views/dialogs/SetDisplayNameDialog.js b/src/components/views/dialogs/SetDisplayNameDialog.js
index c1041cc218..1047e05c26 100644
--- a/src/components/views/dialogs/SetDisplayNameDialog.js
+++ b/src/components/views/dialogs/SetDisplayNameDialog.js
@@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-var React = require("react");
-var sdk = require("../../../index.js");
-var MatrixClientPeg = require("../../../MatrixClientPeg");
+import React from 'react';
+import sdk from '../../../index';
+import MatrixClientPeg from '../../../MatrixClientPeg';
-module.exports = React.createClass({
+/**
+ * Prompt the user to set a display name.
+ *
+ * On success, `onFinished(true, newDisplayName)` is called.
+ */
+export default React.createClass({
displayName: 'SetDisplayNameDialog',
propTypes: {
onFinished: React.PropTypes.func.isRequired,
@@ -42,10 +47,6 @@ module.exports = React.createClass({
this.refs.input_value.select();
},
- getValue: function() {
- return this.state.value;
- },
-
onValueChange: function(ev) {
this.setState({
value: ev.target.value
@@ -54,16 +55,17 @@ module.exports = React.createClass({
onFormSubmit: function(ev) {
ev.preventDefault();
- this.props.onFinished(true);
+ this.props.onFinished(true, this.state.value);
return false;
},
render: function() {
+ const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
-
-
- Set a Display Name
-
+
Your display name is how you'll appear to others when you speak in rooms.
What would you like it to be?
@@ -79,7 +81,7 @@ module.exports = React.createClass({
+
);
- }
+ },
});
diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js
new file mode 100644
index 0000000000..ffea8e1ba7
--- /dev/null
+++ b/src/components/views/elements/AccessibleButton.js
@@ -0,0 +1,54 @@
+/*
+ Copyright 2016 Jani Mustonen
+
+ 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.
+ */
+
+import React from 'react';
+
+/**
+ * AccessibleButton is a generic wrapper for any element that should be treated
+ * as a button. Identifies the element as a button, setting proper tab
+ * indexing and keyboard activation behavior.
+ *
+ * @param {Object} props react element properties
+ * @returns {Object} rendered react
+ */
+export default function AccessibleButton(props) {
+ const {element, onClick, children, ...restProps} = props;
+ restProps.onClick = onClick;
+ restProps.onKeyDown = function(e) {
+ if (e.keyCode == 13 || e.keyCode == 32) return onClick();
+ };
+ restProps.tabIndex = restProps.tabIndex || "0";
+ restProps.role = "button";
+ return React.createElement(element, restProps, children);
+}
+
+/**
+ * children: React's magic prop. Represents all children given to the element.
+ * element: (optional) The base element type. "div" by default.
+ * onClick: (required) Event handler for button activation. Should be
+ * implemented exactly like a normal onClick handler.
+ */
+AccessibleButton.propTypes = {
+ children: React.PropTypes.node,
+ element: React.PropTypes.string,
+ onClick: React.PropTypes.func.isRequired,
+};
+
+AccessibleButton.defaultProps = {
+ element: 'div',
+};
+
+AccessibleButton.displayName = "AccessibleButton";
diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js
index d42637c9e5..9f37fa90ff 100644
--- a/src/components/views/elements/AddressSelector.js
+++ b/src/components/views/elements/AddressSelector.js
@@ -16,18 +16,24 @@ limitations under the License.
'use strict';
-var React = require("react");
-var sdk = require("../../../index");
-var classNames = require('classnames');
+import React from 'react';
+import sdk from '../../../index';
+import classNames from 'classnames';
+import { InviteAddressType } from './AddressTile';
-module.exports = React.createClass({
+export default React.createClass({
displayName: 'AddressSelector',
propTypes: {
onSelected: React.PropTypes.func.isRequired,
- addressList: React.PropTypes.array.isRequired,
+
+ // List of the addresses to display
+ addressList: React.PropTypes.arrayOf(InviteAddressType).isRequired,
truncateAt: React.PropTypes.number.isRequired,
selected: React.PropTypes.number,
+
+ // Element to put as a header on top of the list
+ header: React.PropTypes.node,
},
getInitialState: function() {
@@ -119,7 +125,7 @@ module.exports = React.createClass({
// method, how far to scroll when using the arrow keys
addressList.push(
;
+ ;
}
// TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet
@@ -682,7 +694,7 @@ module.exports = WithMatrixClient(React.createClass({
const EmojiText = sdk.getComponent('elements.EmojiText');
return (
);
diff --git a/src/components/views/rooms/SimpleRoomHeader.js b/src/components/views/rooms/SimpleRoomHeader.js
index 4c63be5b99..bc2f4bca69 100644
--- a/src/components/views/rooms/SimpleRoomHeader.js
+++ b/src/components/views/rooms/SimpleRoomHeader.js
@@ -19,6 +19,7 @@ limitations under the License.
var React = require('react');
var sdk = require('../../../index');
var dis = require("../../../dispatcher");
+import AccessibleButton from '../elements/AccessibleButton';
/*
* A stripped-down room header used for things like the user settings
@@ -44,7 +45,7 @@ module.exports = React.createClass({
var cancelButton;
if (this.props.onCancelClick) {
- cancelButton =
;
+ cancelButton = ;
}
var showRhsButton;
@@ -70,4 +71,3 @@ module.exports = React.createClass({
);
},
});
-
diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js
index a011d5262e..8b53a0e779 100644
--- a/src/components/views/settings/ChangePassword.js
+++ b/src/components/views/settings/ChangePassword.js
@@ -18,7 +18,9 @@ limitations under the License.
var React = require('react');
var MatrixClientPeg = require("../../../MatrixClientPeg");
+var Modal = require("../../../Modal");
var sdk = require("../../../index");
+import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({
displayName: 'ChangePassword',
@@ -65,26 +67,42 @@ module.exports = React.createClass({
changePassword: function(old_password, new_password) {
var cli = MatrixClientPeg.get();
- var authDict = {
- type: 'm.login.password',
- user: cli.credentials.userId,
- password: old_password
- };
+ var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+ Modal.createDialog(QuestionDialog, {
+ title: "Warning",
+ description:
+
+ Changing password will currently reset any end-to-end encryption keys on all devices,
+ making encrypted chat history unreadable.
+ This will be improved shortly,
+ but for now be warned.
+
);
case this.Phases.Uploading:
diff --git a/src/dispatcher.js b/src/dispatcher.js
index ed0350fe54..9864cb3807 100644
--- a/src/dispatcher.js
+++ b/src/dispatcher.js
@@ -28,7 +28,6 @@ class MatrixDispatcher extends flux.Dispatcher {
* for.
*/
dispatch(payload, sync) {
- console.log("Dispatch: "+payload.action);
if (sync) {
super.dispatch(payload);
} else {
@@ -42,6 +41,9 @@ class MatrixDispatcher extends flux.Dispatcher {
}
}
+// XXX this is a big anti-pattern, and makes testing hard. Because dispatches
+// happen asynchronously, it is possible for actions dispatched in one thread
+// to arrive in another, with *hilarious* consequences.
if (global.mxDispatcher === undefined) {
global.mxDispatcher = new MatrixDispatcher();
}
diff --git a/src/index.js b/src/index.js
index 5d4145a39b..b6d8c0b5f4 100644
--- a/src/index.js
+++ b/src/index.js
@@ -27,28 +27,3 @@ module.exports.resetSkin = function() {
module.exports.getComponent = function(componentName) {
return Skinner.getComponent(componentName);
};
-
-
-/* hacky functions for megolm import/export until we give it a UI */
-import * as MegolmExportEncryption from './utils/MegolmExportEncryption';
-import MatrixClientPeg from './MatrixClientPeg';
-
-window.exportKeys = function(password) {
- return MatrixClientPeg.get().exportRoomKeys().then((k) => {
- return MegolmExportEncryption.encryptMegolmKeyFile(
- JSON.stringify(k), password
- );
- }).then((f) => {
- console.log(new TextDecoder().decode(new Uint8Array(f)));
- }).done();
-};
-
-window.importKeys = function(password, data) {
- const arrayBuffer = new TextEncoder().encode(data).buffer;
- return MegolmExportEncryption.decryptMegolmKeyFile(
- arrayBuffer, password
- ).then((j) => {
- const k = JSON.parse(j);
- return MatrixClientPeg.get().importRoomKeys(k);
- });
-};
diff --git a/test/.eslintrc.js b/test/.eslintrc.js
new file mode 100644
index 0000000000..4cc4659d7d
--- /dev/null
+++ b/test/.eslintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+ env: {
+ mocha: true,
+ },
+}
diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js
new file mode 100644
index 0000000000..d01d705040
--- /dev/null
+++ b/test/components/views/elements/MemberEventListSummary-test.js
@@ -0,0 +1,681 @@
+const expect = require('expect');
+const React = require('react');
+const ReactDOM = require("react-dom");
+const ReactTestUtils = require('react-addons-test-utils');
+const sdk = require('matrix-react-sdk');
+const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
+
+const testUtils = require('../../../test-utils');
+describe('MemberEventListSummary', function() {
+ let sandbox;
+
+ // Generate dummy event tiles for use in simulating an expanded MELS
+ const generateTiles = (events) => {
+ return events.map((e) => {
+ return (
+
+ Expanded membership
+
+ );
+ });
+ };
+
+ /**
+ * Generates a membership event with the target of the event set as a mocked
+ * RoomMember based on `parameters.userId`.
+ * @param {string} eventId the ID of the event.
+ * @param {object} parameters the parameters to use to create the event.
+ * @param {string} parameters.membership the membership to assign to
+ * `content.membership`
+ * @param {string} parameters.userId the state key and target userId of the event. If
+ * `parameters.senderId` is not specified, this is also used as the event sender.
+ * @param {string} parameters.prevMembership the membership to assign to
+ * `prev_content.membership`.
+ * @param {string} parameters.senderId the user ID of the sender of the event.
+ * Optional. Defaults to `parameters.userId`.
+ * @returns {MatrixEvent} the event created.
+ */
+ const generateMembershipEvent = (eventId, parameters) => {
+ const e = testUtils.mkMembership({
+ event: true,
+ user: parameters.senderId || parameters.userId,
+ skey: parameters.userId,
+ mship: parameters.membership,
+ prevMship: parameters.prevMembership,
+ target: {
+ // Use localpart as display name
+ name: parameters.userId.match(/@([^:]*):/)[1],
+ userId: parameters.userId,
+ getAvatarUrl: () => {
+ return "avatar.jpeg";
+ },
+ },
+ });
+ // Override random event ID to allow for equality tests against tiles from
+ // generateTiles
+ e.event.event_id = eventId;
+ return e;
+ };
+
+ // Generate mock MatrixEvents from the array of parameters
+ const generateEvents = (parameters) => {
+ const res = [];
+ for (let i = 0; i < parameters.length; i++) {
+ res.push(generateMembershipEvent(`event${i}`, parameters[i]));
+ }
+ return res;
+ };
+
+ // Generate the same sequence of `events` for `n` users, where each user ID
+ // is created by replacing the first "$" in userIdTemplate with `i` for
+ // `i = 0 .. n`.
+ const generateEventsForUsers = (userIdTemplate, n, events) => {
+ let eventsForUsers = [];
+ let userId = "";
+ for (let i = 0; i < n; i++) {
+ userId = userIdTemplate.replace('$', i);
+ events.forEach((e) => {
+ e.userId = userId;
+ });
+ eventsForUsers = eventsForUsers.concat(generateEvents(events));
+ }
+ return eventsForUsers;
+ };
+
+ beforeEach(function() {
+ testUtils.beforeEach(this);
+ sandbox = testUtils.stubClient();
+ });
+
+ afterEach(function() {
+ sandbox.restore();
+ });
+
+ it('renders expanded events if there are less than props.threshold', function() {
+ const events = generateEvents([
+ {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"},
+ ]);
+ const props = {
+ events: events,
+ children: generateTiles(events),
+ summaryLength: 1,
+ avatarsMaxLength: 5,
+ threshold: 3,
+ };
+
+ const renderer = ReactTestUtils.createRenderer();
+ renderer.render();
+ const result = renderer.getRenderOutput();
+
+ expect(result.type).toBe('div');
+ expect(result.props.children).toEqual([
+