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

-
-
- -
-
- -
-
- -
-
-
+ _startExport: function(passphrase) { + // extra Promise.resolve() to turn synchronous exceptions into + // asynchronous ones. + Promise.resolve().then(() => { + return this.props.matrixClient.exportRoomKeys(); + }).then((k) => { + return MegolmExportEncryption.encryptMegolmKeyFile( + JSON.stringify(k), passphrase ); - } + }).then((f) => { + const blob = new Blob([f], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'riot-keys.txt'); + this.props.onFinished(true); + }).catch((e) => { + if (this._unmounted) { + return; + } + this.setState({ + errStr: e.message, + phase: PHASE_EDIT, + }); + }); + + this.setState({ + errStr: null, + phase: PHASE_EXPORTING, + }); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton'); + + const disableForm = (this.state.phase === PHASE_EXPORTING); return ( -
-
- Export room keys -
- {content} -
+ +
+
+

+ This process allows 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. +

+
+ {this.state.errStr} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ + + Cancel + +
+
+
); }, }); diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js new file mode 100644 index 0000000000..586bd9b6cc --- /dev/null +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -0,0 +1,170 @@ +/* +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 Matrix from 'matrix-js-sdk'; +import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; +import sdk from '../../../index'; + +function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + resolve(e.target.result); + }; + reader.onerror = reject; + + reader.readAsArrayBuffer(file); + }); +} + +const PHASE_EDIT = 1; +const PHASE_IMPORTING = 2; + +export default React.createClass({ + displayName: 'ImportE2eKeysDialog', + + propTypes: { + matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + enableSubmit: false, + phase: PHASE_EDIT, + errStr: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _onFormChange: function(ev) { + const files = this.refs.file.files || []; + this.setState({ + enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0), + }); + }, + + _onFormSubmit: function(ev) { + ev.preventDefault(); + this._startImport(this.refs.file.files[0], this.refs.passphrase.value); + return false; + }, + + _startImport: function(file, passphrase) { + this.setState({ + errStr: null, + phase: PHASE_IMPORTING, + }); + + return readFileAsArrayBuffer(file).then((arrayBuffer) => { + return MegolmExportEncryption.decryptMegolmKeyFile( + arrayBuffer, passphrase + ); + }).then((keys) => { + return this.props.matrixClient.importRoomKeys(JSON.parse(keys)); + }).then(() => { + // TODO: it would probably be nice to give some feedback about what we've imported here. + this.props.onFinished(true); + }).catch((e) => { + if (this._unmounted) { + return; + } + this.setState({ + errStr: e.message, + phase: PHASE_EDIT, + }); + }); + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton'); + + const disableForm = (this.state.phase !== PHASE_EDIT); + + return ( + +
+
+

+ This process allows you to import encryption keys + that you had previously exported from another Matrix + client. You will then be able to decrypt any + messages that the other client could decrypt. +

+

+ The export file will be protected with a passphrase. + You should enter the passphrase here, to decrypt the + file. +

+
+ {this.state.errStr} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ + + Cancel + +
+
+
+ ); + }, +}); diff --git a/src/component-index.js b/src/component-index.js index dcf96a6e56..5b28be0627 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -71,6 +71,8 @@ import views$create_room$Presets from './components/views/create_room/Presets'; views$create_room$Presets && (module.exports.components['views.create_room.Presets'] = views$create_room$Presets); import views$create_room$RoomAlias from './components/views/create_room/RoomAlias'; views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias); +import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog'; +views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog); import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog'; views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; @@ -79,8 +81,6 @@ import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog'; views$dialogs$ErrorDialog && (module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog); import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog'; views$dialogs$InteractiveAuthDialog && (module.exports.components['views.dialogs.InteractiveAuthDialog'] = views$dialogs$InteractiveAuthDialog); -import views$dialogs$LogoutPrompt from './components/views/dialogs/LogoutPrompt'; -views$dialogs$LogoutPrompt && (module.exports.components['views.dialogs.LogoutPrompt'] = views$dialogs$LogoutPrompt); import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog'; views$dialogs$NeedToRegisterDialog && (module.exports.components['views.dialogs.NeedToRegisterDialog'] = views$dialogs$NeedToRegisterDialog); import views$dialogs$QuestionDialog from './components/views/dialogs/QuestionDialog'; @@ -91,6 +91,8 @@ import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputD views$dialogs$TextInputDialog && (module.exports.components['views.dialogs.TextInputDialog'] = views$dialogs$TextInputDialog); import views$dialogs$UnknownDeviceDialog from './components/views/dialogs/UnknownDeviceDialog'; views$dialogs$UnknownDeviceDialog && (module.exports.components['views.dialogs.UnknownDeviceDialog'] = views$dialogs$UnknownDeviceDialog); +import views$elements$AccessibleButton from './components/views/elements/AccessibleButton'; +views$elements$AccessibleButton && (module.exports.components['views.elements.AccessibleButton'] = views$elements$AccessibleButton); import views$elements$AddressSelector from './components/views/elements/AddressSelector'; views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector); import views$elements$AddressTile from './components/views/elements/AddressTile'; diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 57a4d4c721..c00bd2c6db 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -21,6 +21,7 @@ import KeyCode from '../../KeyCode'; import Notifier from '../../Notifier'; import PageTypes from '../../PageTypes'; import sdk from '../../index'; +import dis from '../../dispatcher'; /** * This is what our MatrixChat shows when we are logged in. The precise view is diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 20d59e22ec..cb61041d48 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -259,8 +259,6 @@ module.exports = React.createClass({ }, onAction: function(payload) { - console.log("onAction: "+payload.action); - var roomIndexDelta = 1; var self = this; @@ -1008,8 +1006,8 @@ module.exports = React.createClass({ var ForgotPassword = sdk.getComponent('structures.login.ForgotPassword'); var LoggedInView = sdk.getComponent('structures.LoggedInView'); - console.log("rendering; loading="+this.state.loading+"; screen="+this.state.screen + - "; logged_in="+this.state.logged_in+"; ready="+this.state.ready); + // console.log("rendering; loading="+this.state.loading+"; screen="+this.state.screen + + // "; logged_in="+this.state.logged_in+"; ready="+this.state.ready); if (this.state.loading) { var Spinner = sdk.getComponent('elements.Spinner'); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 64b0a8e875..dcebe38fa4 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -281,7 +281,6 @@ module.exports = React.createClass({ var isMembershipChange = (e) => e.getType() === 'm.room.member' - && ['join', 'leave'].indexOf(e.getContent().membership) !== -1 && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership); for (i = 0; i < this.props.events.length; i++) { diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 618989a75c..3ba73bb181 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -21,7 +21,10 @@ var WhoIsTyping = require("../../WhoIsTyping"); var MatrixClientPeg = require("../../MatrixClientPeg"); const MemberAvatar = require("../views/avatars/MemberAvatar"); -const TYPING_AVATARS_LIMIT = 2; +const HIDE_DEBOUNCE_MS = 10000; +const STATUS_BAR_HIDDEN = 0; +const STATUS_BAR_EXPANDED = 1; +const STATUS_BAR_EXPANDED_LARGE = 2; module.exports = React.createClass({ displayName: 'RoomStatusBar', @@ -48,6 +51,10 @@ module.exports = React.createClass({ // more interesting) hasActiveCall: React.PropTypes.bool, + // Number of names to display in typing indication. E.g. set to 3, will + // result in "X, Y, Z and 100 others are typing." + whoIsTypingLimit: React.PropTypes.number, + // callback for when the user clicks on the 'resend all' button in the // 'unsent messages' bar onResendAllClick: React.PropTypes.func, @@ -63,12 +70,28 @@ module.exports = React.createClass({ // status bar. This is used to trigger a re-layout in the parent // component. onResize: React.PropTypes.func, + + // callback for when the status bar can be hidden from view, as it is + // not displaying anything + onHidden: React.PropTypes.func, + // callback for when the status bar is displaying something and should + // be visible + onVisible: React.PropTypes.func, + }, + + getDefaultProps: function() { + return { + whoIsTypingLimit: 2, + }; }, getInitialState: function() { return { syncState: MatrixClientPeg.get().getSyncState(), - whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + whoisTypingString: WhoIsTyping.whoIsTypingString( + this.props.room, + this.props.whoIsTypingLimit + ), }; }, @@ -81,6 +104,18 @@ module.exports = React.createClass({ if(this.props.onResize && this._checkForResize(prevProps, prevState)) { this.props.onResize(); } + + const size = this._getSize(this.state, this.props); + if (size > 0) { + this.props.onVisible(); + } else { + if (this.hideDebouncer) { + clearTimeout(this.hideDebouncer); + } + this.hideDebouncer = setTimeout(() => { + this.props.onHidden(); + }, HIDE_DEBOUNCE_MS); + } }, componentWillUnmount: function() { @@ -103,39 +138,35 @@ module.exports = React.createClass({ onRoomMemberTyping: function(ev, member) { this.setState({ - whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + whoisTypingString: WhoIsTyping.whoIsTypingString( + this.props.room, + this.props.whoIsTypingLimit + ), }); }, + // We don't need the actual height - just whether it is likely to have + // changed - so we use '0' to indicate normal size, and other values to + // indicate other sizes. + _getSize: function(state, props) { + if (state.syncState === "ERROR" || + state.whoisTypingString || + props.numUnreadMessages || + !props.atEndOfLiveTimeline || + props.hasActiveCall) { + return STATUS_BAR_EXPANDED; + } else if (props.tabCompleteEntries) { + return STATUS_BAR_HIDDEN; + } else if (props.hasUnsentMessages) { + return STATUS_BAR_EXPANDED_LARGE; + } + return STATUS_BAR_HIDDEN; + }, + // determine if we need to call onResize _checkForResize: function(prevProps, prevState) { - // figure out the old height and the new height of the status bar. We - // don't need the actual height - just whether it is likely to have - // changed - so we use '0' to indicate normal size, and other values to - // indicate other sizes. - var oldSize, newSize; - - if (prevState.syncState === "ERROR") { - oldSize = 1; - } else if (prevProps.tabCompleteEntries) { - oldSize = 0; - } else if (prevProps.hasUnsentMessages) { - oldSize = 2; - } else { - oldSize = 0; - } - - if (this.state.syncState === "ERROR") { - newSize = 1; - } else if (this.props.tabCompleteEntries) { - newSize = 0; - } else if (this.props.hasUnsentMessages) { - newSize = 2; - } else { - newSize = 0; - } - - return newSize != oldSize; + // figure out the old height and the new height of the status bar. + return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state); }, // return suitable content for the image on the left of the status bar. @@ -177,7 +208,7 @@ module.exports = React.createClass({ if (wantPlaceholder) { return (
- {this._renderTypingIndicatorAvatars(TYPING_AVATARS_LIMIT)} + {this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit)}
); } @@ -186,7 +217,7 @@ module.exports = React.createClass({ }, _renderTypingIndicatorAvatars: function(limit) { - let users = WhoIsTyping.usersTyping(this.props.room); + let users = WhoIsTyping.usersTypingApartFromMe(this.props.room); let othersCount = Math.max(users.length - limit, 0); users = users.slice(0, limit); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 1f35e41817..24c8ff53c0 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -146,6 +146,8 @@ module.exports = React.createClass({ showTopUnreadMessagesBar: false, auxPanelMaxHeight: undefined, + + statusBarVisible: false, }; }, @@ -720,15 +722,11 @@ module.exports = React.createClass({ if (!result.displayname) { var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog'); var dialog_defer = q.defer(); - var dialog_ref; Modal.createDialog(SetDisplayNameDialog, { currentDisplayName: result.displayname, - ref: (r) => { - dialog_ref = r; - }, - onFinished: (submitted) => { + onFinished: (submitted, newDisplayName) => { if (submitted) { - cli.setDisplayName(dialog_ref.getValue()).done(() => { + cli.setDisplayName(newDisplayName).done(() => { dialog_defer.resolve(); }); } @@ -1333,6 +1331,18 @@ module.exports = React.createClass({ // no longer anything to do here }, + onStatusBarVisible: function() { + this.setState({ + statusBarVisible: true, + }); + }, + + onStatusBarHidden: function() { + this.setState({ + statusBarVisible: false, + }); + }, + showSettings: function(show) { // XXX: this is a bit naughty; we should be doing this via props if (show) { @@ -1515,7 +1525,10 @@ module.exports = React.createClass({ onCancelAllClick={this.onCancelAllClick} onScrollToBottomClick={this.jumpToLiveTimeline} onResize={this.onChildResize} - />; + onVisible={this.onStatusBarVisible} + onHidden={this.onStatusBarHidden} + whoIsTypingLimit={2} + />; } var aux = null; @@ -1669,6 +1682,10 @@ module.exports = React.createClass({ ); } + let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable"; + if (this.state.statusBarVisible) { + statusBarAreaClass += " mx_RoomView_statusArea_expanded"; + } return (
@@ -1691,7 +1708,7 @@ module.exports = React.createClass({ { topUnreadMessagesBar } { messagePanel } { searchResultsPanel } -
+
{ 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. +
, + button: "Sign out", + onFinished: (confirmed) => { + if (confirmed) { + dis.dispatch({action: 'logout'}); + if (this.props.onFinished) { + this.props.onFinished(); + } + } + }, + }); }, onPasswordChangeError: function(err) { @@ -392,6 +411,30 @@ module.exports = React.createClass({ }).done(); }, + _onExportE2eKeysClicked: function() { + Modal.createDialogAsync( + (cb) => { + require.ensure(['../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { + cb(require('../../async-components/views/dialogs/ExportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + } + ); + }, + + _onImportE2eKeysClicked: function() { + Modal.createDialogAsync( + (cb) => { + require.ensure(['../../async-components/views/dialogs/ImportE2eKeysDialog'], () => { + cb(require('../../async-components/views/dialogs/ImportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + } + ); + }, + _renderUserInterfaceSettings: function() { var client = MatrixClientPeg.get(); @@ -462,6 +505,23 @@ module.exports = React.createClass({ const deviceId = client.deviceId; const identityKey = client.getDeviceEd25519Key() || ""; + let exportButton = null, + importButton = null; + + if (client.isCryptoEnabled) { + exportButton = ( + + Export E2E room keys + + ); + importButton = ( + + Import E2E room keys + + ); + } return (

Cryptography

@@ -470,6 +530,8 @@ module.exports = React.createClass({
  • {deviceId}
  • {identityKey}
  • + {exportButton} + {importButton}
    ); @@ -531,9 +593,9 @@ module.exports = React.createClass({ return

    Deactivate Account

    - +
    ; }, @@ -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({
    -
    + Sign out -
    + {accountJsx}
    diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 5037136b1d..2c10052b98 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -87,10 +87,26 @@ module.exports = React.createClass({ this.showErrorDialog("New passwords must match each other."); } else { - this.submitPasswordReset( - this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl, - this.state.email, this.state.password - ); + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Warning", + description: +
    + 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.
    ; } else { + const addressSelectorHeader =
    + Searching known users +
    ; addressSelector = ( {this.addressSelector = ref;}} addressList={ this.state.queryList } onSelected={ this.onSelected } - truncateAt={ TRUNCATE_QUERY_LIST } /> + truncateAt={ TRUNCATE_QUERY_LIST } + header={ addressSelectorHeader } + /> ); } @@ -417,9 +488,10 @@ module.exports = React.createClass({
    {this.props.title}
    -
    + -
    +
    diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index ed48f10fd7..937595dfa8 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -25,9 +25,10 @@ limitations under the License. * }); */ -var React = require("react"); +import React from 'react'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'ErrorDialog', propTypes: { title: React.PropTypes.string, @@ -49,20 +50,11 @@ module.exports = React.createClass({ }; }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
    -
    - {this.props.title} -
    +
    {this.props.description}
    @@ -71,7 +63,7 @@ module.exports = React.createClass({ {this.props.button}
    -
    + ); - } + }, }); diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 301bba0486..a4abbb17d9 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -111,20 +111,9 @@ export default React.createClass({ }); }, - _onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - if (!this.state.busy) { - this._onCancel(); - } - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - if (this.state.submitButtonEnabled && !this.state.busy) { - this._onSubmit(); - } + _onEnterPressed: function(e) { + if (this.state.submitButtonEnabled && !this.state.busy) { + this._onSubmit(); } }, @@ -171,6 +160,7 @@ export default React.createClass({ render: function() { const Loader = sdk.getComponent("elements.Spinner"); + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); let error = null; if (this.state.errorText) { @@ -200,10 +190,11 @@ export default React.createClass({ ); return ( -
    -
    - {this.props.title} -
    +

    This operation requires additional authentication.

    {this._renderCurrentStage()} @@ -213,7 +204,7 @@ export default React.createClass({ {submitButton} {cancelButton}
    -
    + ); }, }); 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 ( -
    -
    - Sign out? -
    -
    - - -
    -
    - ); - } -}); - diff --git a/src/components/views/dialogs/NeedToRegisterDialog.js b/src/components/views/dialogs/NeedToRegisterDialog.js index 0080e0c643..f4df5913d5 100644 --- a/src/components/views/dialogs/NeedToRegisterDialog.js +++ b/src/components/views/dialogs/NeedToRegisterDialog.js @@ -23,8 +23,9 @@ limitations under the License. * }); */ -var React = require("react"); -var dis = require("../../../dispatcher"); +import React from 'react'; +import dis from '../../../dispatcher'; +import sdk from '../../../index'; module.exports = React.createClass({ displayName: 'NeedToRegisterDialog', @@ -54,11 +55,12 @@ module.exports = React.createClass({ }, render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
    -
    - {this.props.title} -
    +
    {this.props.description}
    @@ -70,7 +72,7 @@ module.exports = React.createClass({ Register
    -
    + ); - } + }, }); diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 1cd4d047fd..3f7f237c30 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'QuestionDialog', propTypes: { title: React.PropTypes.string, @@ -46,25 +47,13 @@ module.exports = React.createClass({ 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); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
    -
    - {this.props.title} -
    +
    {this.props.description}
    @@ -77,7 +66,7 @@ module.exports = React.createClass({ Cancel
    -
    + ); - } + }, }); 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/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 6245b5786f..6e40efffd8 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'TextInputDialog', propTypes: { title: React.PropTypes.string, @@ -27,7 +28,7 @@ module.exports = React.createClass({ value: React.PropTypes.string, button: React.PropTypes.string, focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired + onFinished: React.PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -36,7 +37,7 @@ module.exports = React.createClass({ value: "", description: "", button: "OK", - focus: true + focus: true, }; }, @@ -55,25 +56,13 @@ module.exports = React.createClass({ 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() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
    -
    - {this.props.title} -
    +
    @@ -90,7 +79,7 @@ module.exports = React.createClass({ {this.props.button}
    -
    +
    ); - } + }, }); 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(
    { this.addressListElement = ref; }} > - +
    ); } @@ -141,6 +147,7 @@ module.exports = React.createClass({ return (
    {this.scrollElement = ref;}}> + { this.props.header } { this.createAddressListTiles() }
    ); diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index 9fc45d582c..01c1ed3255 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -23,16 +23,33 @@ var Invite = require("../../../Invite"); var MatrixClientPeg = require("../../../MatrixClientPeg"); var Avatar = require('../../../Avatar'); -module.exports = React.createClass({ +// React PropType definition for an object describing +// an address that can be invited to a room (which +// could be a third party identifier or a matrix ID) +// along with some additional information about the +// address / target. +export const InviteAddressType = React.PropTypes.shape({ + addressType: React.PropTypes.oneOf([ + 'mx', 'email' + ]).isRequired, + address: React.PropTypes.string.isRequired, + displayName: React.PropTypes.string, + avatarMxc: React.PropTypes.string, + // true if the address is known to be a valid address (eg. is a real + // user we've seen) or false otherwise (eg. is just an address the + // user has entered) + isKnown: React.PropTypes.bool, +}); + + +export default React.createClass({ displayName: 'AddressTile', propTypes: { - address: React.PropTypes.string.isRequired, + address: InviteAddressType.isRequired, canDismiss: React.PropTypes.bool, onDismissed: React.PropTypes.func, justified: React.PropTypes.bool, - networkName: React.PropTypes.string, - networkUrl: React.PropTypes.string, }, getDefaultProps: function() { @@ -40,37 +57,30 @@ module.exports = React.createClass({ canDismiss: false, onDismissed: function() {}, // NOP justified: false, - networkName: "", - networkUrl: "", }; }, render: function() { - var userId, name, imgUrl, email; - var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + const address = this.props.address; + const name = address.displayName || address.address; - // Check if the addr is a valid type - var addrType = Invite.getAddressType(this.props.address); - if (addrType === "mx") { - let user = MatrixClientPeg.get().getUser(this.props.address); - if (user) { - userId = user.userId; - name = user.rawDisplayName || userId; - imgUrl = Avatar.avatarUrlForUser(user, 25, 25, "crop"); - } else { - name=this.props.address; - imgUrl = "img/icon-mx-user.svg"; - } - } else if (addrType === "email") { - email = this.props.address; - name="email"; - imgUrl = "img/icon-email-user.svg"; - } else { - name="Unknown"; - imgUrl = "img/avatar-error.svg"; + let imgUrl; + if (address.avatarMxc) { + imgUrl = MatrixClientPeg.get().mxcUrlToHttp( + address.avatarMxc, 25, 25, 'crop' + ); } + if (address.addressType === "mx") { + if (!imgUrl) imgUrl = 'img/icon-mx-user.svg'; + } else if (address.addressType === 'email') { + if (!imgUrl) imgUrl = 'img/icon-email-user.svg'; + } else { + if (!imgUrl) imgUrl = "img/avatar-error.svg"; + } + + // Removing networks for now as they're not really supported + /* var network; if (this.props.networkUrl !== "") { network = ( @@ -79,16 +89,20 @@ module.exports = React.createClass({
    ); } + */ - var info; - var error = false; - if (addrType === "mx" && userId) { - var nameClasses = classNames({ + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + + let info; + let error = false; + if (address.addressType === "mx" && address.isKnown) { + const nameClasses = classNames({ "mx_AddressTile_name": true, "mx_AddressTile_justified": this.props.justified, }); - var idClasses = classNames({ + const idClasses = classNames({ "mx_AddressTile_id": true, "mx_AddressTile_justified": this.props.justified, }); @@ -96,26 +110,26 @@ module.exports = React.createClass({ info = (
    { name }
    -
    { userId }
    +
    { address.address }
    ); - } else if (addrType === "mx") { - var unknownMxClasses = classNames({ + } else if (address.addressType === "mx") { + const unknownMxClasses = classNames({ "mx_AddressTile_unknownMx": true, "mx_AddressTile_justified": this.props.justified, }); info = ( -
    { this.props.address }
    +
    { this.props.address.address }
    ); - } else if (email) { + } else if (address.addressType === "email") { var emailClasses = classNames({ "mx_AddressTile_email": true, "mx_AddressTile_justified": this.props.justified, }); info = ( -
    { email }
    +
    { address.address }
    ); } else { error = true; @@ -129,12 +143,12 @@ module.exports = React.createClass({ ); } - var classes = classNames({ + const classes = classNames({ "mx_AddressTile": true, "mx_AddressTile_error": error, }); - var dismiss; + let dismiss; if (this.props.canDismiss) { dismiss = (
    @@ -145,7 +159,6 @@ module.exports = React.createClass({ return (
    - { network }
    diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 518439b1c7..61fa0e076f 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -24,7 +24,7 @@ module.exports = React.createClass({ events: React.PropTypes.array.isRequired, // An array of EventTiles to render when expanded children: React.PropTypes.array.isRequired, - // The maximum number of names to show in either the join or leave summaries + // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left" summaryLength: React.PropTypes.number, // The maximum number of avatars to display in the summary avatarsMaxLength: React.PropTypes.number, @@ -40,110 +40,12 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - summaryLength: 3, + summaryLength: 1, threshold: 3, avatarsMaxLength: 5, }; }, - _toggleSummary: function() { - this.setState({ - expanded: !this.state.expanded, - }); - }, - - _getEventSenderName: function(ev) { - if (!ev) { - return 'undefined'; - } - return ev.sender.name || ev.event.content.displayname || ev.getSender(); - }, - - _renderNameList: function(events) { - if (events.length === 0) { - return null; - } - let originalNumber = events.length; - events = events.slice(0, this.props.summaryLength); - let lastEvent = events.pop(); - - let names = events.map((ev) => { - return this._getEventSenderName(ev); - }).join(', '); - - let lastName = this._getEventSenderName(lastEvent); - if (names.length === 0) { - // special-case for a single event - return lastName; - } - - let remaining = originalNumber - this.props.summaryLength; - if (remaining > 0) { - // name1, name2, name3, and 100 others - return names + ', ' + lastName + ', and ' + remaining + ' others'; - } else { - // name1, name2 and name3 - return names + ' and ' + lastName; - } - }, - - _renderSummary: function(joinEvents, leaveEvents) { - let joiners = this._renderNameList(joinEvents); - let leavers = this._renderNameList(leaveEvents); - - let joinSummary = null; - if (joiners) { - joinSummary = ( - - {joiners} joined the room - - ); - } - let leaveSummary = null; - if (leavers) { - leaveSummary = ( - - {leavers} left the room - - ); - } - - // The joinEvents and leaveEvents are representative of the net movement - // per-user, and so it is possible that the total net movement is nil, - // whilst there are some events in the expanded list. If the total net - // movement is nil, then neither joinSummary nor leaveSummary will be - // truthy, so return null. - if (!joinSummary && !leaveSummary) { - return null; - } - - return ( - - {joinSummary}{joinSummary && leaveSummary?'; ':''} - {leaveSummary}.  - - ); - }, - - _renderAvatars: function(events) { - let avatars = events.slice(0, this.props.avatarsMaxLength).map((e) => { - return ( - - ); - }); - - return ( - - {avatars} - - ); - }, - shouldComponentUpdate: function(nextProps, nextState) { // Update if // - The number of summarised events has changed @@ -157,10 +59,296 @@ module.exports = React.createClass({ ); }, + _toggleSummary: function() { + this.setState({ + expanded: !this.state.expanded, + }); + }, + + /** + * Render the JSX for users aggregated by their transition sequences (`eventAggregates`) where + * the sequences are ordered by `orderedTransitionSequences`. + * @param {object[]} eventAggregates a map of transition sequence to array of user display names + * or user IDs. + * @param {string[]} orderedTransitionSequences an array which is some ordering of + * `Object.keys(eventAggregates)`. + * @returns {ReactElement} a single containing the textual summary of the aggregated + * events that occurred. + */ + _renderSummary: function(eventAggregates, orderedTransitionSequences) { + const summaries = orderedTransitionSequences.map((transitions) => { + const userNames = eventAggregates[transitions]; + const nameList = this._renderNameList(userNames); + const plural = userNames.length > 1; + + const splitTransitions = transitions.split(','); + + // Some neighbouring transitions are common, so canonicalise some into "pair" + // transitions + const canonicalTransitions = this._getCanonicalTransitions(splitTransitions); + // Transform into consecutive repetitions of the same transition (like 5 + // consecutive 'joined_and_left's) + const coalescedTransitions = this._coalesceRepeatedTransitions( + canonicalTransitions + ); + + const descs = coalescedTransitions.map((t) => { + return this._getDescriptionForTransition( + t.transitionType, plural, t.repeats + ); + }); + + const desc = this._renderCommaSeparatedList(descs); + + return nameList + " " + desc; + }); + + if (!summaries) { + return null; + } + + return ( + + {summaries.join(", ")} + + ); + }, + + /** + * @param {string[]} users an array of user display names or user IDs. + * @returns {string} a comma-separated list that ends with "and [n] others" if there are + * more items in `users` than `this.props.summaryLength`, which is the number of names + * included before "and [n] others". + */ + _renderNameList: function(users) { + return this._renderCommaSeparatedList(users, this.props.summaryLength); + }, + + /** + * Canonicalise an array of transitions such that some pairs of transitions become + * single transitions. For example an input ['joined','left'] would result in an output + * ['joined_and_left']. + * @param {string[]} transitions an array of transitions. + * @returns {string[]} an array of transitions. + */ + _getCanonicalTransitions: function(transitions) { + const modMap = { + 'joined': { + 'after': 'left', + 'newTransition': 'joined_and_left', + }, + 'left': { + 'after': 'joined', + 'newTransition': 'left_and_joined', + }, + // $currentTransition : { + // 'after' : $nextTransition, + // 'newTransition' : 'new_transition_type', + // }, + }; + const res = []; + + for (let i = 0; i < transitions.length; i++) { + const t = transitions[i]; + const t2 = transitions[i + 1]; + + let transition = t; + + if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) { + transition = modMap[t].newTransition; + i++; + } + + res.push(transition); + } + return res; + }, + + /** + * Transform an array of transitions into an array of transitions and how many times + * they are repeated consecutively. + * + * An array of 123 "joined_and_left" transitions, would result in: + * ``` + * [{ + * transitionType: "joined_and_left" + * repeats: 123 + * }] + * ``` + * @param {string[]} transitions the array of transitions to transform. + * @returns {object[]} an array of coalesced transitions. + */ + _coalesceRepeatedTransitions: function(transitions) { + const res = []; + for (let i = 0; i < transitions.length; i++) { + if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { + res[res.length - 1].repeats += 1; + } else { + res.push({ + transitionType: transitions[i], + repeats: 1, + }); + } + } + return res; + }, + + /** + * For a certain transition, t, describe what happened to the users that + * underwent the transition. + * @param {string} t the transition type. + * @param {boolean} plural whether there were multiple users undergoing the same + * transition. + * @param {number} repeats the number of times the transition was repeated in a row. + * @returns {string} the written English equivalent of the transition. + */ + _getDescriptionForTransition(t, plural, repeats) { + const beConjugated = plural ? "were" : "was"; + const invitation = "their invitation" + (plural || (repeats > 1) ? "s" : ""); + + let res = null; + const map = { + "joined": "joined", + "left": "left", + "joined_and_left": "joined and left", + "left_and_joined": "left and rejoined", + "invite_reject": "rejected " + invitation, + "invite_withdrawal": "had " + invitation + " withdrawn", + "invited": beConjugated + " invited", + "banned": beConjugated + " banned", + "unbanned": beConjugated + " unbanned", + "kicked": beConjugated + " kicked", + }; + + if (Object.keys(map).includes(t)) { + res = map[t] + (repeats > 1 ? " " + repeats + " times" : "" ); + } + + return res; + }, + + /** + * Constructs a written English string representing `items`, with an optional limit on + * the number of items included in the result. If specified and if the length of + *`items` is greater than the limit, the string "and n others" will be appended onto + * the result. + * If `items` is empty, returns the empty string. If there is only one item, return + * it. + * @param {string[]} items the items to construct a string from. + * @param {number?} itemLimit the number by which to limit the list. + * @returns {string} a string constructed by joining `items` with a comma between each + * item, but with the last item appended as " and [lastItem]". + */ + _renderCommaSeparatedList(items, itemLimit) { + const remaining = itemLimit === undefined ? 0 : Math.max( + items.length - itemLimit, 0 + ); + if (items.length === 0) { + return ""; + } else if (items.length === 1) { + return items[0]; + } else if (remaining) { + items = items.slice(0, itemLimit); + const other = " other" + (remaining > 1 ? "s" : ""); + return items.join(', ') + ' and ' + remaining + other; + } else { + const lastItem = items.pop(); + return items.join(', ') + ' and ' + lastItem; + } + }, + + _renderAvatars: function(roomMembers) { + const avatars = roomMembers.slice(0, this.props.avatarsMaxLength).map((m) => { + return ( + + ); + }); + return ( + + {avatars} + + ); + }, + + _getTransitionSequence: function(events) { + return events.map(this._getTransition); + }, + + /** + * Label a given membership event, `e`, where `getContent().membership` has + * changed for each transition allowed by the Matrix protocol. This attempts to + * label the membership changes that occur in `../../../TextForEvent.js`. + * @param {MatrixEvent} e the membership change event to label. + * @returns {string?} the transition type given to this event. This defaults to `null` + * if a transition is not recognised. + */ + _getTransition: function(e) { + switch (e.mxEvent.getContent().membership) { + case 'invite': return 'invited'; + case 'ban': return 'banned'; + case 'join': return 'joined'; + case 'leave': + if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return 'invite_reject'; + default: return 'left'; + } + } + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return 'invite_withdrawal'; + case 'ban': return 'unbanned'; + case 'join': return 'kicked'; + default: return 'left'; + } + default: return null; + } + }, + + _getAggregate: function(userEvents) { + // A map of aggregate type to arrays of display names. Each aggregate type + // is a comma-delimited string of transitions, e.g. "joined,left,kicked". + // The array of display names is the array of users who went through that + // sequence during eventsToRender. + const aggregate = { + // $aggregateType : []:string + }; + // A map of aggregate types to the indices that order them (the index of + // the first event for a given transition sequence) + const aggregateIndices = { + // $aggregateType : int + }; + + const users = Object.keys(userEvents); + users.forEach( + (userId) => { + const firstEvent = userEvents[userId][0]; + const displayName = firstEvent.displayName; + + const seq = this._getTransitionSequence(userEvents[userId]); + if (!aggregate[seq]) { + aggregate[seq] = []; + aggregateIndices[seq] = -1; + } + + aggregate[seq].push(displayName); + + if (aggregateIndices[seq] === -1 || + firstEvent.index < aggregateIndices[seq]) { + aggregateIndices[seq] = firstEvent.index; + } + } + ); + + return { + names: aggregate, + indices: aggregateIndices, + }; + }, + render: function() { - let eventsToRender = this.props.events; - let fewEvents = eventsToRender.length < this.props.threshold; - let expanded = this.state.expanded || fewEvents; + const eventsToRender = this.props.events; + const fewEvents = eventsToRender.length < this.props.threshold; + const expanded = this.state.expanded || fewEvents; let expandedEvents = null; if (expanded) { @@ -175,70 +363,56 @@ module.exports = React.createClass({ ); } - // Map user IDs to the first and last member events in eventsToRender for each user - let userEvents = { - // $userId : {first : e0, last : e1} + // Map user IDs to an array of objects: + const userEvents = { + // $userId : [{ + // // The original event + // mxEvent: e, + // // The display name of the user (if not, then user ID) + // displayName: e.target.name || userId, + // // The original index of the event in this.props.events + // index: index, + // }] }; - eventsToRender.forEach((e) => { + const avatarMembers = []; + eventsToRender.forEach((e, index) => { const userId = e.getStateKey(); // Initialise a user's events if (!userEvents[userId]) { - userEvents[userId] = {first: null, last: null}; + userEvents[userId] = []; + avatarMembers.push(e.target); } - if (!userEvents[userId].first) { - userEvents[userId].first = e; - } - userEvents[userId].last = e; + userEvents[userId].push({ + mxEvent: e, + displayName: e.target.name || userId, + index: index, + }); }); - // Populate the join/leave event arrays with events that represent what happened - // overall to a user's membership. If no events are added to either array for a - // particular user, they will be considered a user that "joined and left". - let joinEvents = []; - let leaveEvents = []; - let joinedAndLeft = 0; - let senders = Object.keys(userEvents); - senders.forEach( - (userId) => { - let firstEvent = userEvents[userId].first; - let lastEvent = userEvents[userId].last; + const aggregate = this._getAggregate(userEvents); - // Membership BEFORE eventsToRender - let previousMembership = firstEvent.getPrevContent().membership || "leave"; - - // If the last membership event differs from previousMembership, use that. - if (previousMembership !== lastEvent.getContent().membership) { - if (lastEvent.event.content.membership === 'join') { - joinEvents.push(lastEvent); - } else if (lastEvent.event.content.membership === 'leave') { - leaveEvents.push(lastEvent); - } - } else { - // Increment the number of users whose membership change was nil overall - joinedAndLeft++; - } - } + // Sort types by order of lowest event index within sequence + const orderedTransitionSequences = Object.keys(aggregate.names).sort( + (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2] ); - let avatars = this._renderAvatars(joinEvents.concat(leaveEvents)); - let summary = this._renderSummary(joinEvents, leaveEvents); - let toggleButton = ( + const avatars = this._renderAvatars(avatarMembers); + const summary = this._renderSummary(aggregate.names, orderedTransitionSequences); + const toggleButton = ( {expanded ? 'collapse' : 'expand'} ); - let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users'; - let noun = (joinedAndLeft === 1 ? 'user' : plural); - let summaryContainer = ( + const summaryContainer = (
    {avatars} - {summary}{joinedAndLeft ? joinedAndLeft + ' ' + noun + ' joined and left' : ''} + {summary}   {toggleButton}
    diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js index 0157131506..401a11c1cb 100644 --- a/src/components/views/elements/TintableSvg.js +++ b/src/components/views/elements/TintableSvg.js @@ -69,6 +69,7 @@ var TintableSvg = React.createClass({ width={ this.props.width } height={ this.props.height } onLoad={ this.onLoad } + tabIndex="-1" /> ); } diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index d29137ffc2..71e8fb0be7 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -20,6 +20,7 @@ var React = require('react'); var MatrixClientPeg = require('../../../MatrixClientPeg'); var sdk = require('../../../index'); +import AccessibleButton from '../elements/AccessibleButton'; var PRESENCE_CLASS = { @@ -152,7 +153,7 @@ module.exports = React.createClass({ var av = this.props.avatarJsx || ; return ( -
    @@ -161,7 +162,7 @@ module.exports = React.createClass({
    { nameEl } { inviteButton } -
    + ); } }); diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 16a047f72d..d33b8f3524 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -35,6 +35,7 @@ var DMRoomMap = require('../../../utils/DMRoomMap'); var Unread = require('../../../Unread'); var Receipt = require('../../../utils/Receipt'); var WithMatrixClient = require('../../../wrappers/WithMatrixClient'); +import AccessibleButton from '../elements/AccessibleButton'; module.exports = WithMatrixClient(React.createClass({ displayName: 'MemberInfo', @@ -612,7 +613,7 @@ module.exports = WithMatrixClient(React.createClass({ mx_MemberInfo_createRoom_label: true, mx_RoomTile_name: true, }); - const startNewChat =
    @@ -620,7 +621,7 @@ module.exports = WithMatrixClient(React.createClass({
    Start new chat
    -
    ; + ; startChat =

    Direct chats

    @@ -635,26 +636,37 @@ module.exports = WithMatrixClient(React.createClass({ } if (this.state.can.kick) { - kickButton =
    - { this.props.member.membership === "invite" ? "Disinvite" : "Kick" } -
    ; + const membership = this.props.member.membership; + const kickLabel = membership === "invite" ? "Disinvite" : "Kick"; + kickButton = ( + + {kickLabel} + + ); } if (this.state.can.ban) { - banButton =
    - Ban -
    ; + banButton = ( + + Ban + + ); } if (this.state.can.mute) { - var muteLabel = this.state.muted ? "Unmute" : "Mute"; - muteButton =
    - {muteLabel} -
    ; + const muteLabel = this.state.muted ? "Unmute" : "Mute"; + muteButton = ( + + {muteLabel} + + ); } if (this.state.can.toggleMod) { var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator"; - giveModButton =
    + giveModButton = {giveOpLabel} -
    ; + ; } // TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet @@ -682,7 +694,7 @@ module.exports = WithMatrixClient(React.createClass({ const EmojiText = sdk.getComponent('elements.EmojiText'); return (
    - +
    diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js index c1fe4431bf..f8ce50bf5d 100644 --- a/src/components/views/rooms/ReadReceiptMarker.js +++ b/src/components/views/rooms/ReadReceiptMarker.js @@ -192,9 +192,9 @@ module.exports = React.createClass({ width={14} height={14} resizeMethod="crop" style={style} title={title} - onClick={this.props.onClick} /> ); + /* onClick={this.props.onClick} */ }, }); diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index e345918f07..fa0c63dfdd 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -26,6 +26,7 @@ var rate_limited_func = require('../../../ratelimitedfunc'); var linkify = require('linkifyjs'); var linkifyElement = require('linkifyjs/element'); var linkifyMatrix = require('../../../linkify-matrix'); +import AccessibleButton from '../elements/AccessibleButton'; linkifyMatrix(linkify); @@ -182,8 +183,8 @@ module.exports = React.createClass({ 'm.room.name', user_id ); - save_button =
    Save
    ; - cancel_button =
    Cancel
    ; + save_button = Save; + cancel_button = Cancel ; } if (this.props.saving) { @@ -275,9 +276,9 @@ module.exports = React.createClass({ var settings_button; if (this.props.onSettingsClick) { settings_button = -
    + -
    ; + ; } // var leave_button; @@ -291,17 +292,17 @@ module.exports = React.createClass({ var forget_button; if (this.props.onForgetClick) { forget_button = -
    + -
    ; + ; } var rightPanel_buttons; if (this.props.collapsedRhs) { rightPanel_buttons = -
    + -
    ; + ; } var right_row; @@ -310,9 +311,9 @@ module.exports = React.createClass({
    { settings_button } { forget_button } -
    + -
    + { rightPanel_buttons }
    ; } diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 83b9cf3c6f..f6c0f7034e 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -26,6 +26,7 @@ var sdk = require('../../../index'); var ContextualMenu = require('../../structures/ContextualMenu'); var RoomNotifs = require('../../../RoomNotifs'); var FormattingUtils = require('../../../utils/FormattingUtils'); +import AccessibleButton from '../elements/AccessibleButton'; var UserSettingsStore = require('../../../UserSettingsStore'); module.exports = React.createClass({ @@ -288,8 +289,10 @@ module.exports = React.createClass({ var connectDragSource = this.props.connectDragSource; var connectDropTarget = this.props.connectDropTarget; + let ret = ( -
    +
    { /* Only native elements can be wrapped in a DnD object. */} +
    @@ -304,6 +307,7 @@ module.exports = React.createClass({
    {/* { incomingCallBox } */} { tooltip } +
    ); 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 =
    Cancel
    ; + cancelButton = Cancel ; } 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. +
    , + button: "Continue", + onFinished: (confirmed) => { + if (confirmed) { + var authDict = { + type: 'm.login.password', + user: cli.credentials.userId, + password: old_password + }; - this.setState({ - phase: this.Phases.Uploading + this.setState({ + phase: this.Phases.Uploading + }); + + var self = this; + cli.setPassword(authDict, new_password).then(function() { + self.props.onFinished(); + }, function(err) { + self.props.onError(err); + }).finally(function() { + self.setState({ + phase: self.Phases.Edit + }); + }).done(); + } + }, }); - - var self = this; - cli.setPassword(authDict, new_password).then(function() { - self.props.onFinished(); - }, function(err) { - self.props.onError(err); - }).finally(function() { - self.setState({ - phase: self.Phases.Edit - }); - }).done(); }, onClickChange: function() { @@ -136,9 +154,10 @@ module.exports = React.createClass({
    -
    + Change Password -
    +
    ); 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([ +
    Expanded membership
    , + ]); + }); + + it('renders expanded events if there are less than props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + ]); + 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([ +
    Expanded membership
    , +
    Expanded membership
    , + ]); + }); + + it('renders collapsed events if events.length = props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe("user_1 joined and left and joined"); + }); + + it('truncates long join,leave repetitions', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe("user_1 joined and left 7 times"); + }); + + it('truncates long join,leave repetitions between other events', function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "invite", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was unbanned, joined and left 7 times and was invited" + ); + }); + + it('truncates multiple sequences of repetitions with other events between', + function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "ban", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "invite", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was unbanned, joined and left 2 times, was banned, " + + "joined and left 3 times and was invited" + ); + }); + + it('handles multiple users following the same sequence of memberships', function() { + const events = generateEvents([ + // user_1 + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + // user_2 + { + userId: "@user_2:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_2:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and 1 other were unbanned, joined and left 2 times and were banned" + ); + }); + + it('handles many users following the same sequence of memberships', function() { + const events = generateEventsForUsers("@user_$:some.domain", 20, [ + { + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {prevMembership: "leave", membership: "join"}, + {prevMembership: "join", membership: "leave"}, + {prevMembership: "leave", membership: "join"}, + {prevMembership: "join", membership: "leave"}, + { + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_0 and 19 others were unbanned, joined and left 2 times and were banned" + ); + }); + + it('correctly orders sequences of transitions by the order of their first event', + function() { + const events = generateEvents([ + { + userId: "@user_2:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_2 was unbanned and joined and left 2 times, user_1 was unbanned, " + + "joined and left 2 times and was banned" + ); + }); + + it('correctly identifies transitions', function() { + const events = generateEvents([ + // invited + {userId: "@user_1:some.domain", membership: "invite"}, + // banned + {userId: "@user_1:some.domain", membership: "ban"}, + // joined + {userId: "@user_1:some.domain", membership: "join"}, + // invite_reject + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + // left + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + // invite_withdrawal + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // unbanned + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // kicked + { + userId: "@user_1:some.domain", + prevMembership: "join", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // default = left + { + userId: "@user_1:some.domain", + prevMembership: "????", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was invited, was banned, joined, rejected their invitation, left, " + + "had their invitation withdrawn, was unbanned, was kicked and left" + ); + }); + + it('handles invitation plurals correctly when there are multiple users', function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + { + userId: "@user_2:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_2:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and 1 other rejected their invitations and " + + "had their invitations withdrawn" + ); + }); + + it('handles invitation plurals correctly when there are multiple invites', + function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 1, // threshold = 1 to force collapse + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 rejected their invitations 2 times" + ); + }); + + it('handles a summary length = 2, with no "others"', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and user_2 joined 2 times" + ); + }); + + it('handles a summary length = 2, with 1 "other"', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + {userId: "@user_3:some.domain", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1, user_2 and 1 other joined" + ); + }); + + it('handles a summary length = 2, with many "others"', function() { + const events = generateEventsForUsers("@user_$:some.domain", 20, [ + {membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_0, user_1 and 18 others joined" + ); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index db405c2e1a..cdfae4421c 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -108,6 +108,7 @@ export function mkEvent(opts) { room_id: opts.room, sender: opts.user, content: opts.content, + prev_content: opts.prev_content, event_id: "$" + Math.random() + "-" + Math.random(), origin_server_ts: opts.ts, }; @@ -150,7 +151,9 @@ export function mkPresence(opts) { * @param {Object} opts Values for the membership. * @param {string} opts.room The room ID for the event. * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.prevMship The prev_content.membership for the event. * @param {string} opts.user The user ID for the event. + * @param {RoomMember} opts.target The target of the event. * @param {string} opts.skey The other user ID for the event if applicable * e.g. for invites/bans. * @param {string} opts.name The content.displayname for the event. @@ -169,9 +172,16 @@ export function mkMembership(opts) { opts.content = { membership: opts.mship }; + if (opts.prevMship) { + opts.prev_content = { membership: opts.prevMship }; + } if (opts.name) { opts.content.displayname = opts.name; } if (opts.url) { opts.content.avatar_url = opts.url; } - return mkEvent(opts); + let e = mkEvent(opts); + if (opts.target) { + e.target = opts.target; + } + return e; }; /**