diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index e2baaed5a6..0000000000 --- a/.eslintrc +++ /dev/null @@ -1,117 +0,0 @@ -{ - "parser": "babel-eslint", - "plugins": [ - "react", - "flowtype" - ], - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true, - "impliedStrict": true - } - }, - "env": { - "browser": true, - "amd": true, - "es6": true, - "node": true, - "mocha": true - }, - "extends": ["eslint:recommended", "plugin:react/recommended"], - "rules": { - "no-undef": ["warn"], - "global-strict": ["off"], - "no-extra-semi": ["warn"], - "no-underscore-dangle": ["off"], - "no-console": ["off"], - "no-unused-vars": ["off"], - "no-trailing-spaces": ["warn", { - "skipBlankLines": true - }], - "no-unreachable": ["warn"], - "no-spaced-func": ["warn"], - "no-new-func": ["error"], - "no-new-wrappers": ["error"], - "no-invalid-regexp": ["error"], - "no-extra-bind": ["error"], - "no-magic-numbers": ["error", { - "ignore": [-1, 0, 1], // usually used in array/string indexing - "ignoreArrayIndexes": true, - "enforceConst": true, - "detectObjects": true - }], - "consistent-return": ["error"], - "valid-jsdoc": ["error"], - "no-use-before-define": ["error"], - "camelcase": ["warn"], - "array-callback-return": ["error"], - "dot-location": ["warn", "property"], - "guard-for-in": ["error"], - "no-useless-call": ["warn"], - "no-useless-escape": ["warn"], - "no-useless-concat": ["warn"], - "brace-style": ["warn", "1tbs"], - "comma-style": ["warn", "last"], - "space-before-function-paren": ["warn", "never"], - "space-before-blocks": ["warn", "always"], - "keyword-spacing": ["warn", { - "before": true, - "after": true - }], - - // dangling commas required, but only for multiline objects/arrays - "comma-dangle": ["warn", "always-multiline"], - // always === instead of ==, unless dealing with null/undefined - "eqeqeq": ["error", "smart"], - // always use curly braces, even with single statements - "curly": ["error", "all"], - // phasing out var in favour of let/const is a good idea - "no-var": ["warn"], - // always require semicolons - "semi": ["error", "always"], - // prefer rest and spread over the Old Ways - "prefer-spread": ["warn"], - "prefer-rest-params": ["warn"], - - /** react **/ - - // bind or arrow function in props causes performance issues - "react/jsx-no-bind": ["error", { - "ignoreRefs": true - }], - "react/jsx-key": ["error"], - "react/prefer-stateless-function": ["warn"], - - /** flowtype **/ - "flowtype/require-parameter-type": [ - 1, - { - "excludeArrowFunctions": true - } - ], - "flowtype/define-flow-type": 1, - "flowtype/require-return-type": [ - 1, - "always", - { - "annotateUndefined": "never", - "excludeArrowFunctions": true - } - ], - "flowtype/space-after-type-colon": [ - 1, - "always" - ], - "flowtype/space-before-type-colon": [ - 1, - "never" - ] - }, - "settings": { - "flowtype": { - "onlyFilesWithFlowAnnotation": true - } - } -} diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..e41106d695 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,62 @@ +module.exports = { + parser: "babel-eslint", + extends: ["./node_modules/matrix-js-sdk/.eslintrc.js"], + plugins: [ + "react", + "flowtype", + ], + env: { + es6: true, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + } + }, + rules: { + /** react **/ + // This just uses the react plugin to help eslint known when + // variables have been used in JSX + "react/jsx-uses-vars": "error", + + // bind or arrow function in props causes performance issues + "react/jsx-no-bind": ["error", { + "ignoreRefs": true, + }], + "react/jsx-key": ["error"], + + /** flowtype **/ + "flowtype/require-parameter-type": ["warn", { + "excludeArrowFunctions": true, + }], + "flowtype/define-flow-type": "warn", + "flowtype/require-return-type": ["warn", + "always", + { + "annotateUndefined": "never", + "excludeArrowFunctions": true, + } + ], + "flowtype/space-after-type-colon": ["warn", "always"], + "flowtype/space-before-type-colon": ["warn", "never"], + + /* + * things that are errors in the js-sdk config that the current + * code does not adhere to, turned down to warn + */ + "max-len": ["warn"], + "valid-jsdoc": ["warn"], + "new-cap": ["warn"], + "key-spacing": ["warn"], + "arrow-parens": ["warn"], + "prefer-const": ["warn"], + + // crashes currently: https://github.com/eslint/eslint/issues/6274 + "generator-star-spacing": "off", + }, + settings: { + flowtype: { + onlyFilesWithFlowAnnotation: true + }, + }, +}; diff --git a/.travis-test-riot.sh b/.travis-test-riot.sh new file mode 100755 index 0000000000..c280044246 --- /dev/null +++ b/.travis-test-riot.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# +# script which is run by the travis build (after `npm run test`). +# +# clones riot-web develop and runs the tests against our version of react-sdk. + +set -ev + +RIOT_WEB_DIR=riot-web +REACT_SDK_DIR=`pwd` + +git clone --depth=1 --branch develop https://github.com/vector-im/riot-web.git \ + "$RIOT_WEB_DIR" + +cd "$RIOT_WEB_DIR" + +mkdir node_modules +npm install + +(cd node_modules/matrix-js-sdk && npm install) + +rm -r node_modules/matrix-react-sdk +ln -s "$REACT_SDK_DIR" node_modules/matrix-react-sdk + +npm run test diff --git a/.travis.yml b/.travis.yml index 6d68b66a1c..9a8f804644 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,3 +4,6 @@ node_js: install: - npm install - (cd node_modules/matrix-js-sdk && npm install) +script: + - npm run test + - ./.travis-test-riot.sh 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 906417a953..0a8c09f984 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", @@ -67,13 +68,14 @@ "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.11.1", + "text-encoding-utf-8": "^1.0.1", "velocity-vector": "vector-im/velocity#059e3b2", "whatwg-fetch": "^1.0.0" }, "devDependencies": { "babel-cli": "^6.5.2", "babel-core": "^6.14.0", - "babel-eslint": "^6.1.0", + "babel-eslint": "^6.1.2", "babel-loader": "^6.2.5", "babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-transform-async-to-generator": "^6.16.0", @@ -85,9 +87,10 @@ "babel-preset-es2016": "^6.11.3", "babel-preset-es2017": "^6.14.0", "babel-preset-react": "^6.11.1", - "eslint": "^2.13.1", - "eslint-plugin-flowtype": "^2.17.0", - "eslint-plugin-react": "^6.2.1", + "eslint": "^3.13.1", + "eslint-config-google": "^0.7.1", + "eslint-plugin-flowtype": "^2.30.0", + "eslint-plugin-react": "^6.9.0", "expect": "^1.16.0", "json-loader": "^0.5.3", "karma": "^0.13.22", diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 5593d46ff7..d6a1d58aa0 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -21,7 +21,7 @@ var MatrixClientPeg = require("./MatrixClientPeg"); * optionally, the identity servers. * * This involves getting an email token from the identity server to "prove" that - * the client owns the given email address, which is then passed to the + * the client owns the given email address, which is then passed to the * add threepid API on the homeserver. */ class AddThreepid { diff --git a/src/Avatar.js b/src/Avatar.js index 0ef6c8d07b..76f5e55ff0 100644 --- a/src/Avatar.js +++ b/src/Avatar.js @@ -49,12 +49,12 @@ module.exports = { }, defaultAvatarUrlForString: function(s) { - var images = [ '76cfa6', '50e2c2', 'f4c371' ]; + var images = ['76cfa6', '50e2c2', 'f4c371']; var total = 0; for (var i = 0; i < s.length; ++i) { total += s.charCodeAt(i); } return 'img/' + images[total % images.length] + '.png'; } -} +}; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 815133c334..8bdf7d0391 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -41,7 +41,7 @@ export default class BasePlatform { * Returns true if the platform supports displaying * notifications, otherwise false. */ - supportsNotifications() : boolean { + supportsNotifications(): boolean { return false; } @@ -49,7 +49,7 @@ export default class BasePlatform { * Returns true if the application currently has permission * to display notifications. Otherwise false. */ - maySendNotifications() : boolean { + maySendNotifications(): boolean { return false; } @@ -60,7 +60,7 @@ export default class BasePlatform { * that is 'granted' if the user allowed the request or * 'denied' otherwise. */ - requestNotificationPermission() : Promise { + requestNotificationPermission(): Promise { } displayNotification(title: string, msg: string, avatarUrl: string, room: Object) { diff --git a/src/CallHandler.js b/src/CallHandler.js index 31b52b65a3..268a599d8e 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -159,10 +159,10 @@ function _setCallState(call, roomId, status) { calls[roomId] = call; if (status === "ringing") { - play("ringAudio") + play("ringAudio"); } else if (call && call.call_state === "ringing") { - pause("ringAudio") + pause("ringAudio"); } if (call) { diff --git a/src/ContentMessages.js b/src/ContentMessages.js index c169ce64b5..17c8155c1b 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -256,7 +256,7 @@ function uploadFile(matrixClient, roomId, file) { }); }); } else { - const basePromise = matrixClient.uploadContent(file); + const basePromise = matrixClient.uploadContent(file); const promise1 = basePromise.then(function(url) { // If the attachment isn't encrypted then include the URL directly. return {"url": url}; diff --git a/src/DateUtils.js b/src/DateUtils.js index 2b51c5903f..07bab4ae7b 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -48,5 +48,5 @@ module.exports = { //return pad(date.getHours()) + ':' + pad(date.getMinutes()); return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2); } -} +}; diff --git a/src/Entities.js b/src/Entities.js index ac3c976797..7c3909f36f 100644 --- a/src/Entities.js +++ b/src/Entities.js @@ -136,6 +136,6 @@ module.exports = { fromUsers: function(users, showInviteButton, inviteFn) { return users.map(function(u) { return new UserEntity(u, showInviteButton, inviteFn); - }) + }); } }; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index fc1630b6fb..c7b13bc071 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -91,16 +91,16 @@ var sanitizeHtmlParams = { ], allowedAttributes: { // custom ones first: - font: [ 'color' ], // custom to matrix - a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix + font: ['color'], // custom to matrix + a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix // We don't currently allow img itself by default, but this // would make sense if we did - img: [ 'src' ], + img: ['src'], }, // Lots of these won't come up by default because we don't allow them - selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ], + selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit - allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], + allowedSchemes: ['http', 'https', 'ftp', 'mailto'], // DO NOT USE. sanitize-html allows all URL starting with '//' // so this will always allow links to whatever scheme the diff --git a/src/ImageUtils.js b/src/ImageUtils.js index fdb12c7608..3744241874 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.js @@ -53,5 +53,5 @@ module.exports = { return Math.floor(heightMulti * fullHeight); } }, -} +}; diff --git a/src/Invite.js b/src/Invite.js index 6cb04b1b19..d1f03fe211 100644 --- a/src/Invite.js +++ b/src/Invite.js @@ -58,29 +58,7 @@ export function inviteToRoom(roomId, addr) { * @returns Promise */ export function inviteMultipleToRoom(roomId, addrs) { - this.inviter = new MultiInviter(roomId); - return this.inviter.invite(addrs); + const inviter = new MultiInviter(roomId); + return inviter.invite(addrs); } -/** - * Checks is the supplied address is valid - * - * @param {addr} The mx userId or email address to check - * @returns true, false, or null for unsure - */ -export function isValidAddress(addr) { - // Check if the addr is a valid type - var addrType = this.getAddressType(addr); - if (addrType === "mx") { - let user = MatrixClientPeg.get().getUser(addr); - if (user) { - return true; - } else { - return null; - } - } else if (addrType === "email") { - return true; - } else { - return false; - } -} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0a61dc6105..493bbf12aa 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -18,7 +18,7 @@ import q from 'q'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; -import Notifier from './Notifier' +import Notifier from './Notifier'; import UserActivity from './UserActivity'; import Presence from './Presence'; import dis from './dispatcher'; @@ -140,7 +140,7 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) { homeserverUrl: queryParams.homeserver, identityServerUrl: queryParams.identityServer, guest: false, - }) + }); }, (err) => { console.error("Failed to log in with login token: " + err + " " + err.data); diff --git a/src/Markdown.js b/src/Markdown.js index 18c888b541..2f278183a3 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -23,7 +23,9 @@ import commonmark from 'commonmark'; */ export default class Markdown { constructor(input) { - this.input = input + this.input = input; + this.parser = new commonmark.Parser(); + this.renderer = new commonmark.HtmlRenderer({safe: false}); } isPlainText() { @@ -47,8 +49,9 @@ export default class Markdown { dummy_renderer[k] = setNotPlain; } // text and paragraph are just text - dummy_renderer.text = function(t) { return t; } - dummy_renderer.paragraph = function(t) { return t; } + dummy_renderer.text = function(t) { return t; }; + dummy_renderer.softbreak = function(t) { return t; }; + dummy_renderer.paragraph = function(t) { return t; }; const dummy_parser = new commonmark.Parser(); dummy_renderer.render(dummy_parser.parse(this.input)); @@ -57,11 +60,9 @@ export default class Markdown { } toHTML() { - const parser = new commonmark.Parser(); + const real_paragraph = this.renderer.paragraph; - const renderer = new commonmark.HtmlRenderer({safe: true}); - const real_paragraph = renderer.paragraph; - renderer.paragraph = function(node, entering) { + this.renderer.paragraph = function(node, entering) { // If there is only one top level node, just return the // bare text: it's a single line of text and so should be // 'inline', rather than unnecessarily wrapped in its own @@ -69,14 +70,55 @@ export default class Markdown { // its own p tag to keep them as separate paragraphs. var par = node; while (par.parent) { - par = par.parent + par = par.parent; } if (par.firstChild != par.lastChild) { real_paragraph.call(this, node, entering); } - } + }; - var parsed = parser.parse(this.input); - return renderer.render(parsed); + var parsed = this.parser.parse(this.input); + var rendered = this.renderer.render(parsed); + + this.renderer.paragraph = real_paragraph; + + return rendered; + } + + toPlaintext() { + const real_paragraph = this.renderer.paragraph; + + // The default `out` function only sends the input through an XML + // escaping function, which causes messages to be entity encoded, + // which we don't want in this case. + this.renderer.out = function(s) { + // The `lit` function adds a string literal to the output buffer. + this.lit(s); + }; + + this.renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + var par = node; + while (par.parent) { + node = par; + par = par.parent; + } + if (node != par.lastChild) { + if (!entering) { + this.lit('\n\n'); + } + } + }; + + var parsed = this.parser.parse(this.input); + var rendered = this.renderer.render(parsed); + + this.renderer.paragraph = real_paragraph; + + return rendered; } } diff --git a/src/Modal.js b/src/Modal.js index 44072b9278..f0ab97a91e 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,6 +19,55 @@ limitations under the License. var React = require('react'); var ReactDOM = require('react-dom'); +import sdk from './index'; + +/** + * Wrap an asynchronous loader function with a react component which shows a + * spinner until the real component loads. + */ +const AsyncWrapper = React.createClass({ + propTypes: { + /** A function which takes a 'callback' argument which it will call + * with the real component once it loads. + */ + loader: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + component: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + this.props.loader((e) => { + if (this._unmounted) { + return; + } + this.setState({component: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + render: function() { + const {loader, ...otherProps} = this.props; + + if (this.state.component) { + const Component = this.state.component; + return ; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); + +let _counter = 0; module.exports = { DialogContainerId: "mx_Dialog_Container", @@ -35,21 +84,47 @@ module.exports = { return container; }, - createDialog: function (Element, props, className) { - var self = this; + createDialog: function(Element, props, className) { + return this.createDialogAsync((cb) => {cb(Element);}, props, className); + }, + /** + * Open a modal view. + * + * This can be used to display a react component which is loaded as an asynchronous + * webpack component. To do this, set 'loader' as: + * + * (cb) => { + * require([''], cb); + * } + * + * @param {Function} loader a function which takes a 'callback' argument, + * which it should call with a React component which will be displayed as + * the modal view. + * + * @param {Object} props properties to pass to the displayed + * component. (We will also pass an 'onFinished' property.) + * + * @param {String} className CSS class to apply to the modal wrapper + */ + createDialogAsync: function(loader, props, className) { + var self = this; // never call this via modal.close() from onFinished() otherwise it will loop var closeDialog = function() { if (props && props.onFinished) props.onFinished.apply(null, arguments); 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/Notifier.js b/src/Notifier.js index a58fc0132f..67642e734a 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -53,7 +53,7 @@ var Notifier = { if (!msg) return; var title; - if (!ev.sender || room.name == ev.sender.name) { + if (!ev.sender || room.name == ev.sender.name) { title = room.name; // notificationMessageForEvent includes sender, // but we already have the sender here @@ -88,7 +88,7 @@ var Notifier = { if (e) { e.load(); e.play(); - }; + } }, start: function() { diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 07a16df501..5fac588a4f 100644 --- a/src/ObjectUtils.js +++ b/src/ObjectUtils.js @@ -64,7 +64,7 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { } else if (itemDelta[item] === -1) { results.push({ place: "del", key: muxedKey, val: item }); } else { - // itemDelta of 0 means it was unchanged between before/after + // itemDelta of 0 means it was unchanged between before/after } }); break; diff --git a/src/Presence.js b/src/Presence.js index 4152d7a487..c45d571217 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -111,7 +111,7 @@ class Presence { this.timer = setTimeout(function() { self._onUnavailableTimerFire(); }, UNAVAILABLE_TIME_MS); - } + } } module.exports = new Presence(); diff --git a/src/RichText.js b/src/RichText.js index 5fe920fe50..b1793d0ddf 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -12,7 +12,7 @@ import { SelectionState, Entity, } from 'draft-js'; -import * as sdk from './index'; +import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; @@ -109,7 +109,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { return {avatar}{props.children}; } }; - + let roomDecorator = { strategy: (contentBlock, callback) => { findWithRegex(ROOM_REGEX, contentBlock, callback); diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 09f178dd3f..7a43c1891e 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -26,7 +26,7 @@ function tsOfNewestEvent(room) { } function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a,b) { + return roomList.sort(function(a, b) { return tsOfNewestEvent(b) - tsOfNewestEvent(a); }); } diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index d0cdd6ead7..7cb7d4b9de 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -146,7 +146,7 @@ function isRuleForRoom(roomId, rule) { } const cond = rule.conditions[0]; if ( - cond.kind == 'event_match' && + cond.kind == 'event_match' && cond.key == 'room_id' && cond.pattern == roomId ) { diff --git a/src/Rooms.js b/src/Rooms.js index cf62f2dda0..fbcc843ad2 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -37,7 +37,7 @@ export function getOnlyOtherMember(room, me) { if (joinedMembers.length === 2) { return joinedMembers.filter(function(m) { - return m.userId !== me.userId + return m.userId !== me.userId; })[0]; } diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index 75062daaa2..dbb7e405df 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -371,7 +371,7 @@ const onMessage = function(event) { }, (err) => { console.error(err); sendError(event, "Failed to lookup current room."); - }) + }); }; module.exports = { diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 523d1d8f3c..1ddcf4832d 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -41,7 +41,7 @@ class Command { } getUsage() { - return "Usage: " + this.getCommandWithArgs() + return "Usage: " + this.getCommandWithArgs(); } } @@ -84,7 +84,7 @@ var commands = { var matches = args.match(/^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}))( +(#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})))?$/); if (matches) { Tinter.tint(matches[1], matches[4]); - var colorScheme = {} + var colorScheme = {}; colorScheme.primary_color = matches[1]; if (matches[4]) { colorScheme.secondary_color = matches[4]; @@ -288,7 +288,7 @@ var commands = { // helpful aliases var aliases = { j: "join" -} +}; module.exports = { /** @@ -331,9 +331,9 @@ module.exports = { // Return all the commands plus /me and /markdown which aren't handled like normal commands var cmds = Object.keys(commands).sort().map(function(cmdKey) { return commands[cmdKey]; - }) - cmds.push(new Command("me", "", function(){})); - cmds.push(new Command("markdown", "", function(){})); + }); + cmds.push(new Command("me", "", function() {})); + cmds.push(new Command("markdown", "", function() {})); return cmds; } diff --git a/src/TabComplete.js b/src/TabComplete.js index a0380f36c4..59ecc2ae20 100644 --- a/src/TabComplete.js +++ b/src/TabComplete.js @@ -112,7 +112,7 @@ class TabComplete { return; } // ES6 destructuring; ignore first element (the complete match) - var [ , boundaryGroup, partialGroup] = res; + var [, boundaryGroup, partialGroup] = res; if (partialGroup.length === 0 && passive) { return; @@ -254,7 +254,7 @@ class TabComplete { if (ev.ctrlKey || ev.metaKey || ev.altKey) return; // tab key has been pressed at this point - this.handleTabPress(false, ev.shiftKey) + this.handleTabPress(false, ev.shiftKey); // prevent the default TAB operation (typically focus shifting) ev.preventDefault(); @@ -386,6 +386,6 @@ class TabComplete { this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++; } } -}; +} module.exports = TabComplete; diff --git a/src/TabCompleteEntries.js b/src/TabCompleteEntries.js index 2a8c7b383a..e6adec0d7d 100644 --- a/src/TabCompleteEntries.js +++ b/src/TabCompleteEntries.js @@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); var sdk = require("./index"); class Entry { @@ -90,7 +89,7 @@ CommandEntry.fromCommands = function(commandArray) { return commandArray.map(function(cmd) { return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs()); }); -} +}; class MemberEntry extends Entry { constructor(member) { @@ -119,7 +118,7 @@ MemberEntry.fromMemberList = function(members) { return members.map(function(m) { return new MemberEntry(m); }); -} +}; module.exports.Entry = Entry; module.exports.MemberEntry = MemberEntry; diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 2ffd33167f..3f772e9cfb 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -75,7 +75,6 @@ function textForMemberEvent(ev) { return targetName + " joined the room."; } } - return ''; case 'leave': if (ev.getSender() === ev.getStateKey()) { if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { @@ -203,4 +202,4 @@ module.exports = { if (!hdlr) return ""; return hdlr(ev); } -} +}; diff --git a/src/Tinter.js b/src/Tinter.js index 534a1d810b..5bf13e6d4a 100644 --- a/src/Tinter.js +++ b/src/Tinter.js @@ -14,9 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -var dis = require("./dispatcher"); -var sdk = require("./index"); - // FIXME: these vars should be bundled up and attached to // module.exports otherwise this will break when included by both // react-sdk and apps layered on top. @@ -42,6 +39,7 @@ var keyHex = [ "#76CFA6", // Vector Green "#EAF5F0", // Vector Light Green "#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on Vector Light Green) + "#FFFFFF", // white highlights of the SVGs (for switching to dark theme) ]; // cache of our replacement colours @@ -50,6 +48,7 @@ var colors = [ keyHex[0], keyHex[1], keyHex[2], + keyHex[3], ]; var cssFixups = [ @@ -150,7 +149,7 @@ function hexToRgb(color) { function rgbToHex(rgb) { var val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; - return '#' + (0x1000000 + val).toString(16).slice(1) + return '#' + (0x1000000 + val).toString(16).slice(1); } // List of functions to call when the tint changes. @@ -185,7 +184,7 @@ module.exports = { } if (!secondaryColor) { - var x = 0.16; // average weighting factor calculated from vector green & light green + const x = 0.16; // average weighting factor calculated from vector green & light green var rgb = hexToRgb(primaryColor); rgb[0] = x * rgb[0] + (1 - x) * 255; rgb[1] = x * rgb[1] + (1 - x) * 255; @@ -194,7 +193,7 @@ module.exports = { } if (!tertiaryColor) { - var x = 0.19; + const x = 0.19; var rgb1 = hexToRgb(primaryColor); var rgb2 = hexToRgb(secondaryColor); rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; @@ -210,7 +209,9 @@ module.exports = { return; } - colors = [primaryColor, secondaryColor, tertiaryColor]; + colors[0] = primaryColor; + colors[1] = secondaryColor; + colors[2] = tertiaryColor; if (DEBUG) console.log("Tinter.tint"); @@ -224,6 +225,19 @@ module.exports = { }); }, + tintSvgWhite: function(whiteColor) { + if (!whiteColor) { + whiteColor = colors[3]; + } + if (colors[3] === whiteColor) { + return; + } + colors[3] = whiteColor; + tintables.forEach(function(tintable) { + tintable(); + }); + }, + // XXX: we could just move this all into TintableSvg, but as it's so similar // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) // keeping it here for now. diff --git a/src/Velociraptor.js b/src/Velociraptor.js index d9b6b3d5dc..006dbcb0ac 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -76,7 +76,7 @@ module.exports = React.createClass({ var startStyles = self.props.startStyles; if (startStyles.length > 0) { - var startStyle = startStyles[0] + var startStyle = startStyles[0]; newProps.style = startStyle; // console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); } @@ -105,7 +105,7 @@ module.exports = React.createClass({ ) { var startStyles = this.props.startStyles; var transitionOpts = this.props.enterTransitionOpts; - var domNode = ReactDom.findDOMNode(node); + const domNode = ReactDom.findDOMNode(node); // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. for (var i = 1; i < startStyles.length; ++i) { @@ -145,7 +145,7 @@ module.exports = React.createClass({ // and the FAQ entry, "Preventing memory leaks when // creating/destroying large numbers of elements" // (https://github.com/julianshapiro/velocity/issues/47) - var domNode = ReactDom.findDOMNode(this.nodes[k]); + const domNode = ReactDom.findDOMNode(this.nodes[k]); Velocity.Utilities.removeData(domNode); } this.nodes[k] = node; diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js index 168b0b14af..3ad7d207a9 100644 --- a/src/VelocityBounce.js +++ b/src/VelocityBounce.js @@ -6,10 +6,12 @@ function bounce( p ) { var pow2, bounce = 4; - while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {} + while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) { + // just sets pow2 + } return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 ); } Velocity.Easings.easeOutBounce = function(p) { return 1 - bounce(1 - p); -} +}; diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index 4fb5399027..8c3838d615 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -46,4 +46,4 @@ module.exports = { return names.join(', ') + ' and ' + lastPerson + ' are typing'; } } -} +}; diff --git a/src/components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js similarity index 100% rename from src/components/views/dialogs/EncryptedEventDialog.js rename to src/async-components/views/dialogs/EncryptedEventDialog.js diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js new file mode 100644 index 0000000000..284d299f4b --- /dev/null +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -0,0 +1,84 @@ +/* +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 sdk from '../../../index'; + +import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; + +export default React.createClass({ + displayName: 'ExportE2eKeysDialog', + + getInitialState: function() { + return { + collectedPassword: false, + }; + }, + + _onPassphraseFormSubmit: function(ev) { + ev.preventDefault(); + console.log(this.refs.passphrase1.value); + 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. +

+
+
+ +
+
+ +
+
+ +
+
+
+ ); + } + + return ( +
+
+ Export room keys +
+ {content} +
+ ); + }, +}); diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 9cdb774cac..5c90990295 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -26,7 +26,7 @@ export default class AutocompleteProvider { } commandRegex.lastIndex = 0; - + let match; while ((match = commandRegex.exec(query)) != null) { let matchStart = match.index, diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 7d032006db..60171bc72f 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -83,7 +83,7 @@ export default class CommandProvider extends AutocompleteProvider { static getInstance(): CommandProvider { if (instance == null) - instance = new CommandProvider(); + {instance = new CommandProvider();} return instance; } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 46aa4b0f03..bffd924976 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -13,7 +13,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { constructor() { super(DDG_REGEX); } - + static getQueryUri(query: String) { return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}` + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 4c8bf60b83..a2d77f02a1 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -44,7 +44,7 @@ export default class EmojiProvider extends AutocompleteProvider { static getInstance() { if (instance == null) - instance = new EmojiProvider(); + {instance = new EmojiProvider();} return instance; } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index f3401cf1bb..8d1e555e56 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -52,12 +52,12 @@ export default class RoomProvider extends AutocompleteProvider { getName() { return '💬 Rooms'; } - + static getInstance() { if (instance == null) { instance = new RoomProvider(); } - + return instance; } diff --git a/src/component-index.js b/src/component-index.js index bc3d698cac..e83de8739d 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -75,8 +75,6 @@ import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInvit views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; views$dialogs$DeactivateAccountDialog && (module.exports.components['views.dialogs.DeactivateAccountDialog'] = views$dialogs$DeactivateAccountDialog); -import views$dialogs$EncryptedEventDialog from './components/views/dialogs/EncryptedEventDialog'; -views$dialogs$EncryptedEventDialog && (module.exports.components['views.dialogs.EncryptedEventDialog'] = views$dialogs$EncryptedEventDialog); 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'; diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index fecb2a1841..e5a62b8345 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -47,7 +47,7 @@ module.exports = { return container; }, - createMenu: function (Element, props) { + createMenu: function(Element, props) { var self = this; var closeMenu = function() { @@ -67,7 +67,7 @@ module.exports = { chevronOffset.top = props.chevronOffset; } - // To overide the deafult chevron colour, if it's been set + // To override the default chevron colour, if it's been set var chevronCSS = ""; if (props.menuColour) { chevronCSS = ` @@ -78,15 +78,15 @@ module.exports = { .mx_ContextualMenu_chevron_right:after { border-left-color: ${props.menuColour}; } - ` + `; } var chevron = null; if (props.left) { - chevron =
+ chevron =
; position.left = props.left; } else { - chevron =
+ chevron =
; position.right = props.right; } diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js index ce4c0916d4..24ebfea07f 100644 --- a/src/components/structures/CreateRoom.js +++ b/src/components/structures/CreateRoom.js @@ -118,7 +118,7 @@ module.exports = React.createClass({ var self = this; - deferred.then(function (resp) { + deferred.then(function(resp) { self.setState({ phase: self.phases.CREATED, }); @@ -210,7 +210,7 @@ module.exports = React.createClass({ onAliasChanged: function(alias) { this.setState({ alias: alias - }) + }); }, onEncryptChanged: function(ev) { diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 0dd16a7e99..5166619d48 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -35,7 +35,7 @@ var FilePanel = React.createClass({ getInitialState: function() { return { timelineSet: null, - } + }; }, componentWillMount: function() { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 7c0fe14edd..57a4d4c721 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -160,8 +160,8 @@ export default React.createClass({ collapsedRhs={this.props.collapse_rhs} ConferenceHandler={this.props.ConferenceHandler} scrollStateMap={this._scrollStateMap} - /> - if (!this.props.collapse_rhs) right_panel = + />; + if (!this.props.collapse_rhs) right_panel = ; break; case PageTypes.UserSettings: @@ -170,28 +170,28 @@ export default React.createClass({ brand={this.props.config.brand} collapsedRhs={this.props.collapse_rhs} enableLabs={this.props.config.enableLabs} - /> - if (!this.props.collapse_rhs) right_panel = + />; + if (!this.props.collapse_rhs) right_panel = ; break; case PageTypes.CreateRoom: page_element = - if (!this.props.collapse_rhs) right_panel = + />; + if (!this.props.collapse_rhs) right_panel = ; break; case PageTypes.RoomDirectory: page_element = - if (!this.props.collapse_rhs) right_panel = + />; + if (!this.props.collapse_rhs) right_panel = ; break; case PageTypes.UserView: page_element = null; // deliberately null for now - right_panel = + right_panel = ; break; } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index c47109db94..20d59e22ec 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -77,7 +77,7 @@ module.exports = React.createClass({ getChildContext: function() { return { appConfig: this.props.config, - } + }; }, getInitialState: function() { @@ -259,6 +259,8 @@ module.exports = React.createClass({ }, onAction: function(payload) { + console.log("onAction: "+payload.action); + var roomIndexDelta = 1; var self = this; @@ -456,6 +458,9 @@ module.exports = React.createClass({ middleOpacity: payload.middleOpacity, }); break; + case 'set_theme': + this._onSetTheme(payload.value); + break; case 'on_logged_in': this._onLoggedIn(); break; @@ -586,6 +591,50 @@ module.exports = React.createClass({ this.setState({loading: false}); }, + /** + * Called whenever someone changes the theme + */ + _onSetTheme: function(theme) { + if (!theme) { + theme = 'light'; + } + + // look for the stylesheet elements. + // styleElements is a map from style name to HTMLLinkElement. + var styleElements = Object.create(null); + var i, a; + for (i = 0; (a = document.getElementsByTagName("link")[i]); i++) { + var href = a.getAttribute("href"); + // shouldn't we be using the 'title' tag rather than the href? + var match = href.match(/^bundles\/.*\/theme-(.*)\.css$/); + if (match) { + styleElements[match[1]] = a; + } + } + + if (!(theme in styleElements)) { + throw new Error("Unknown theme " + theme); + } + + // disable all of them first, then enable the one we want. Chrome only + // bothers to do an update on a true->false transition, so this ensures + // that we get exactly one update, at the right time. + + Object.values(styleElements).forEach((a) => { + a.disabled = true; + }); + styleElements[theme].disabled = false; + + if (theme === 'dark') { + // abuse the tinter to change all the SVG's #fff to #2d2d2d + // XXX: obviously this shouldn't be hardcoded here. + Tinter.tintSvgWhite('#2d2d2d'); + } + else { + Tinter.tintSvgWhite('#ffffff'); + } + }, + /** * Called when a new logged in session has started */ @@ -687,6 +736,16 @@ module.exports = React.createClass({ action: 'logout' }); }); + cli.on("accountData", function(ev) { + if (ev.getType() === 'im.vector.web.settings') { + if (ev.getContent() && ev.getContent().theme) { + dis.dispatch({ + action: 'set_theme', + value: ev.getContent().theme, + }); + } + } + }); }, onFocus: function(ev) { @@ -949,8 +1008,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'); @@ -979,7 +1038,7 @@ module.exports = React.createClass({ {...this.props} {...this.state} /> - ) + ); } else if (this.state.logged_in) { // we think we are logged in, but are still waiting for the /sync to complete var Spinner = sdk.getComponent('elements.Spinner'); @@ -1003,6 +1062,7 @@ module.exports = React.createClass({ defaultHsUrl={this.getDefaultHsUrl()} defaultIsUrl={this.getDefaultIsUrl()} brand={this.props.config.brand} + teamsConfig={this.props.config.teamsConfig} customHsUrl={this.getCurrentHsUrl()} customIsUrl={this.getCurrentIsUrl()} registrationUrl={this.props.registrationUrl} diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index c04bec4b35..64b0a8e875 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -19,7 +19,7 @@ var ReactDOM = require("react-dom"); var dis = require("../../dispatcher"); var sdk = require('../../index'); -var MatrixClientPeg = require('../../MatrixClientPeg') +var MatrixClientPeg = require('../../MatrixClientPeg'); const MILLIS_IN_DAY = 86400000; @@ -282,7 +282,7 @@ 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); + && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership); for (i = 0; i < this.props.events.length; i++) { var mxEv = this.props.events[i]; @@ -340,7 +340,7 @@ module.exports = React.createClass({ prevEvent = e; return ret; } - ).reduce((a,b) => a.concat(b)); + ).reduce((a, b) => a.concat(b)); if (eventTiles.length === 0) { eventTiles = null; diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index c6f2d6500b..0eb50161ec 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -19,6 +19,14 @@ var sdk = require('../../index'); var dis = require("../../dispatcher"); 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', @@ -60,6 +68,13 @@ 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, }, getInitialState: function() { @@ -78,6 +93,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() { @@ -104,35 +131,24 @@ module.exports = React.createClass({ }); }, + // 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) { + 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. @@ -173,10 +189,8 @@ module.exports = React.createClass({ if (wantPlaceholder) { return ( -
- . - . - . +
+ {this._renderTypingIndicatorAvatars(TYPING_AVATARS_LIMIT)}
); } @@ -184,6 +198,36 @@ module.exports = React.createClass({ return null; }, + _renderTypingIndicatorAvatars: function(limit) { + let users = WhoIsTyping.usersTypingApartFromMe(this.props.room); + + let othersCount = Math.max(users.length - limit, 0); + users = users.slice(0, limit); + + let avatars = users.map((u, index) => { + let showInitial = othersCount === 0 && index === users.length - 1; + return ( + + ); + }); + + if (othersCount > 0) { + avatars.push( + + +{othersCount} + + ); + } + + return avatars; + }, // return suitable content for the main (text) part of the status bar. _getContent: function() { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8059cd9372..8753540e48 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -48,7 +48,7 @@ if (DEBUG) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { - var debuglog = function () {}; + var debuglog = function() {}; } module.exports = React.createClass({ @@ -146,7 +146,9 @@ module.exports = React.createClass({ showTopUnreadMessagesBar: false, auxPanelMaxHeight: undefined, - } + + statusBarVisible: false, + }; }, componentWillMount: function() { @@ -674,8 +676,9 @@ module.exports = React.createClass({ }, onSearchResultsFillRequest: function(backwards) { - if (!backwards) + if (!backwards) { return q(false); + } if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); @@ -758,7 +761,7 @@ module.exports = React.createClass({ }).then(() => { var sign_url = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined; return MatrixClientPeg.get().joinRoom(this.props.roomAddress, - { inviteSignUrl: sign_url } ) + { inviteSignUrl: sign_url } ); }).then(function(resp) { var roomId = resp.roomId; @@ -962,7 +965,7 @@ module.exports = React.createClass({ // For overlapping highlights, // favour longer (more specific) terms first highlights = highlights.sort(function(a, b) { - return b.length - a.length }); + return b.length - a.length; }); self.setState({ searchHighlights: highlights, @@ -1025,7 +1028,7 @@ module.exports = React.createClass({ if (scrollPanel) { scrollPanel.checkScroll(); } - } + }; var lastRoomId; @@ -1090,7 +1093,7 @@ module.exports = React.createClass({ } this.refs.room_settings.save().then((results) => { - var fails = results.filter(function(result) { return result.state !== "fulfilled" }); + var fails = results.filter(function(result) { return result.state !== "fulfilled"; }); console.log("Settings saved with %s errors", fails.length); if (fails.length) { fails.forEach(function(result) { @@ -1099,7 +1102,7 @@ module.exports = React.createClass({ var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Failed to save settings", - description: fails.map(function(result) { return result.reason }).join("\n"), + description: fails.map(function(result) { return result.reason; }).join("\n"), }); // still editing room settings } @@ -1183,7 +1186,7 @@ module.exports = React.createClass({ this.setState({ searching: true }); }, - onCancelSearchClick: function () { + onCancelSearchClick: function() { this.setState({ searching: false, searchResults: null, @@ -1208,8 +1211,9 @@ module.exports = React.createClass({ // decide whether or not the top 'unread messages' bar should be shown _updateTopUnreadMessagesBar: function() { - if (!this.refs.messagePanel) + if (!this.refs.messagePanel) { return; + } var pos = this.refs.messagePanel.getReadMarkerPosition(); @@ -1331,6 +1335,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) { @@ -1498,7 +1514,7 @@ module.exports = React.createClass({ if (ContentMessages.getCurrentUploads().length > 0) { var UploadBar = sdk.getComponent('structures.UploadBar'); - statusBar = + statusBar = ; } else if (!this.state.searchResults) { var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); @@ -1513,7 +1529,9 @@ module.exports = React.createClass({ onCancelAllClick={this.onCancelAllClick} onScrollToBottomClick={this.jumpToLiveTimeline} onResize={this.onChildResize} - /> + onVisible={this.onStatusBarVisible} + onHidden={this.onStatusBarHidden} + />; } var aux = null; @@ -1569,7 +1587,7 @@ module.exports = React.createClass({ messageComposer = + callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>; } // TODO: Why aren't we storing the term/scope/count in this format @@ -1597,14 +1615,14 @@ module.exports = React.createClass({ {call.isLocalVideoMuted() -
+ ; } voiceMuteButton =
{call.isMicrophoneMuted() -
+ ; // wrap the existing status bar into a 'callStatusBar' which adds more knobs. statusBar = @@ -1614,7 +1632,7 @@ module.exports = React.createClass({ { zoomButton } { statusBar } - + ; } // if we have search results, we keep the messagepanel (so that it preserves its @@ -1667,6 +1685,10 @@ module.exports = React.createClass({ ); } + let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable"; + if (this.state.statusBarVisible) { + statusBarAreaClass += " mx_RoomView_statusArea_expanded"; + } return (
@@ -1689,7 +1711,7 @@ module.exports = React.createClass({ { topUnreadMessagesBar } { messagePanel } { searchResultsPanel } -
+
{ statusBar } diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index a9e16d364c..1391d2b740 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -34,7 +34,7 @@ if (DEBUG_SCROLL) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { - var debuglog = function () {}; + var debuglog = function() {}; } /* This component implements an intelligent scrolling list. @@ -600,7 +600,7 @@ module.exports = React.createClass({ stuckAtBottom: false, trackedScrollToken: node.dataset.scrollToken, pixelOffset: wrapperRect.bottom - boundingRect.bottom, - } + }; debuglog("Saved scroll state", this.scrollState); return; } diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index bf31f44315..490b83f2bf 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -38,7 +38,7 @@ if (DEBUG) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { - var debuglog = function () {}; + var debuglog = function() {}; } /* @@ -322,7 +322,7 @@ var TimelinePanel = React.createClass({ }); }, - onMessageListScroll: function () { + onMessageListScroll: function() { if (this.props.onScroll) { this.props.onScroll(); } @@ -387,7 +387,7 @@ var TimelinePanel = React.createClass({ // if we're at the end of the live timeline, append the pending events if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(... this.props.timelineSet.room.getPendingEvents()); + events.push(...this.props.timelineSet.room.getPendingEvents()); } var updatedState = {events: events}; @@ -564,8 +564,9 @@ var TimelinePanel = React.createClass({ // first find where the current RM is for (var i = 0; i < events.length; i++) { - if (events[i].getId() == this.state.readMarkerEventId) + if (events[i].getId() == this.state.readMarkerEventId) { break; + } } if (i >= events.length) { return; @@ -644,7 +645,7 @@ var TimelinePanel = React.createClass({ var tl = this.props.timelineSet.getTimelineForEvent(rmId); var rmTs; if (tl) { - var event = tl.getEvents().find((e) => { return e.getId() == rmId }); + var event = tl.getEvents().find((e) => { return e.getId() == rmId; }); if (event) { rmTs = event.getTs(); } @@ -821,7 +822,7 @@ var TimelinePanel = React.createClass({ description: message, onFinished: onFinished, }); - } + }; var prom = this._timelineWindow.load(eventId, INITIAL_SIZE); @@ -843,7 +844,7 @@ var TimelinePanel = React.createClass({ timelineLoading: true, }); - prom = prom.then(onLoaded, onError) + prom = prom.then(onLoaded, onError); } prom.done(); @@ -868,7 +869,7 @@ var TimelinePanel = React.createClass({ // if we're at the end of the live timeline, append the pending events if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(... this.props.timelineSet.getPendingEvents()); + events.push(...this.props.timelineSet.getPendingEvents()); } return events; @@ -930,8 +931,9 @@ var TimelinePanel = React.createClass({ _getCurrentReadReceipt: function(ignoreSynthesized) { var client = MatrixClientPeg.get(); // the client can be null on logout - if (client == null) + if (client == null) { return null; + } var myUserId = client.credentials.userId; return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 794fcffec7..e91e558cb2 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -57,7 +57,7 @@ module.exports = React.createClass({displayName: 'UploadBar', // }]; if (uploads.length == 0) { - return
+ return
; } var upload; @@ -68,7 +68,7 @@ module.exports = React.createClass({displayName: 'UploadBar', } } if (!upload) { - return
+ return
; } var innerProgressStyle = { @@ -76,7 +76,7 @@ module.exports = React.createClass({displayName: 'UploadBar', }; var uploadedSize = filesize(upload.loaded); var totalSize = filesize(upload.total); - if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) { + if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) { uploadedSize = uploadedSize.replace(/ .*/, ''); } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index a41eab3a76..4a1332be8c 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -32,6 +32,53 @@ var AddThreepid = require('../../AddThreepid'); const REACT_SDK_VERSION = 'dist' in package_json ? package_json.version : package_json.gitHead || ""; + +// Enumerate some simple 'flip a bit' UI settings (if any). +// 'id' gives the key name in the im.vector.web.settings account data event +// 'label' is how we describe it in the UI. +const SETTINGS_LABELS = [ +/* + { + id: 'alwaysShowTimestamps', + label: 'Always show message timestamps', + }, + { + id: 'showTwelveHourTimestamps', + label: 'Show timestamps in 12 hour format (e.g. 2:30pm)', + }, + { + id: 'useCompactLayout', + label: 'Use compact timeline layout', + }, + { + id: 'useFixedWidthFont', + label: 'Use fixed width font', + }, +*/ +]; + +// Enumerate the available themes, with a nice human text label. +// 'id' gives the key name in the im.vector.web.settings account data event +// 'value' is the value for that key in the event +// 'label' is how we describe it in the UI. +// +// XXX: Ideally we would have a theme manifest or something and they'd be nicely +// packaged up in a single directory, and/or located at the application layer. +// But for now for expedience we just hardcode them here. +const THEMES = [ + { + id: 'theme', + label: 'Light theme', + value: 'light', + }, + { + id: 'theme', + label: 'Dark theme', + value: 'dark', + } +]; + + module.exports = React.createClass({ displayName: 'UserSettings', @@ -93,6 +140,12 @@ module.exports = React.createClass({ middleOpacity: 0.3, }); this._refreshFromServer(); + + var syncedSettings = UserSettingsStore.getSyncedSettings(); + if (!syncedSettings.theme) { + syncedSettings.theme = 'light'; + } + this._syncedSettings = syncedSettings; }, componentDidMount: function() { @@ -293,8 +346,8 @@ module.exports = React.createClass({ this.setState({email_add_pending: false}); if (err.errcode == 'M_THREEPID_AUTH_FAILED') { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - var message = "Unable to verify email address. " - message += "Please check your email and click on the link it contains. Once this is done, click continue." + var message = "Unable to verify email address. "; + message += "Please check your email and click on the link it contains. Once this is done, click continue."; Modal.createDialog(QuestionDialog, { title: "Verification Pending", description: message, @@ -342,60 +395,68 @@ module.exports = React.createClass({ _renderUserInterfaceSettings: function() { var client = MatrixClientPeg.get(); - var settingsLabels = [ - /* - { - id: 'alwaysShowTimestamps', - label: 'Always show message timestamps', - }, - { - id: 'showTwelveHourTimestamps', - label: 'Show timestamps in 12 hour format (e.g. 2:30pm)', - }, - { - id: 'useCompactLayout', - label: 'Use compact timeline layout', - }, - { - id: 'useFixedWidthFont', - label: 'Use fixed width font', - }, - */ - ]; - - var syncedSettings = UserSettingsStore.getSyncedSettings(); - return (

User Interface

-
- UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) } - /> - -
+ { this._renderUrlPreviewSelector() } + { SETTINGS_LABELS.map( this._renderSyncedSetting ) } + { THEMES.map( this._renderThemeSelector ) }
- { settingsLabels.forEach( setting => { -
- UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) } - /> - -
- })}
); }, + _renderUrlPreviewSelector: function() { + return
+ UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) } + /> + +
; + }, + + _renderSyncedSetting: function(setting) { + return
+ UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) } + /> + +
; + }, + + _renderThemeSelector: function(setting) { + return
+ { + if (e.target.checked) { + UserSettingsStore.setSyncedSetting(setting.id, setting.value); + } + dis.dispatch({ + action: 'set_theme', + value: setting.value, + }); + } + } + /> + +
; + }, + _renderCryptoInfo: function() { const client = MatrixClientPeg.get(); const deviceId = client.deviceId; @@ -406,8 +467,8 @@ module.exports = React.createClass({

Cryptography

    -
  • {deviceId}
  • -
  • {identityKey}
  • +
  • {deviceId}
  • +
  • {identityKey}
@@ -424,7 +485,7 @@ module.exports = React.createClass({ ); }, - _renderLabs: function () { + _renderLabs: function() { // default to enabled if undefined if (this.props.enableLabs === false) return null; @@ -460,7 +521,7 @@ module.exports = React.createClass({ {features}
- ) + ); }, _renderDeactivateAccount: function() { @@ -544,10 +605,10 @@ module.exports = React.createClass({
- +
-
- Remove +
+ Remove
); @@ -569,7 +630,7 @@ module.exports = React.createClass({ blurToCancel={ false } onValueChanged={ this.onAddThreepidClicked } />
-
+
Add
@@ -650,7 +711,7 @@ module.exports = React.createClass({
diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 1868c2ee73..5037136b1d 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -58,7 +58,7 @@ module.exports = React.createClass({ this.setState({ progress: null }); - }) + }); }, onVerify: function(ev) { @@ -71,7 +71,7 @@ module.exports = React.createClass({ this.setState({ progress: "complete" }); }, (err) => { this.showErrorDialog(err.message); - }) + }); }, onSubmitForm: function(ev) { @@ -129,7 +129,7 @@ module.exports = React.createClass({ var resetPasswordJsx; if (this.state.progress === "sending_email") { - resetPasswordJsx = + resetPasswordJsx = ; } else if (this.state.progress === "sent_email") { resetPasswordJsx = ( diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index c0d0c08d2c..fe9b544751 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -173,7 +173,7 @@ module.exports = React.createClass({ }, _getCurrentFlowStep: function() { - return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null + return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; }, _setStateFromError: function(err, isLoginAttempt) { @@ -195,7 +195,7 @@ module.exports = React.createClass({ } let errorText = "Error: Problem communicating with the given homeserver " + - (errCode ? "(" + errCode + ")" : "") + (errCode ? "(" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -258,7 +258,7 @@ module.exports = React.createClass({ loginAsGuestJsx = Login as guest - + ; } var returnToAppJsx; @@ -266,7 +266,7 @@ module.exports = React.createClass({ returnToAppJsx = Return to app - + ; } return ( diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 269aabed9b..90140b3280 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -49,6 +49,21 @@ module.exports = React.createClass({ email: React.PropTypes.string, username: React.PropTypes.string, guestAccessToken: React.PropTypes.string, + teamsConfig: React.PropTypes.shape({ + // Email address to request new teams + supportEmail: React.PropTypes.string, + teams: React.PropTypes.arrayOf(React.PropTypes.shape({ + // The displayed name of the team + "name": React.PropTypes.string, + // The suffix with which every team email address ends + "emailSuffix": React.PropTypes.string, + // The rooms to use during auto-join + "rooms": React.PropTypes.arrayOf(React.PropTypes.shape({ + "id": React.PropTypes.string, + "autoJoin": React.PropTypes.bool, + })), + })).required, + }), defaultDeviceDisplayName: React.PropTypes.string, @@ -169,6 +184,26 @@ module.exports = React.createClass({ accessToken: response.access_token }); + // Auto-join rooms + if (self.props.teamsConfig && self.props.teamsConfig.teams) { + for (let i = 0; i < self.props.teamsConfig.teams.length; i++) { + let team = self.props.teamsConfig.teams[i]; + if (self.state.formVals.email.endsWith(team.emailSuffix)) { + console.log("User successfully registered with team " + team.name); + if (!team.rooms) { + break; + } + team.rooms.forEach((room) => { + if (room.autoJoin) { + console.log("Auto-joining " + room.id); + MatrixClientPeg.get().joinRoom(room.id); + } + }); + break; + } + } + } + if (self.props.brand) { MatrixClientPeg.get().getPushers().done((resp)=>{ var pushers = resp.pushers; @@ -254,6 +289,7 @@ module.exports = React.createClass({ defaultUsername={this.state.formVals.username} defaultEmail={this.state.formVals.email} defaultPassword={this.state.formVals.password} + teamsConfig={this.props.teamsConfig} guestUsername={this.props.username} minPasswordLength={MIN_PASSWORD_LENGTH} onError={this.onFormValidationFailed} @@ -297,7 +333,7 @@ module.exports = React.createClass({ returnToAppJsx = Return to app - + ; } return ( diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 47f0a76891..363f340fad 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -41,7 +41,7 @@ module.exports = React.createClass({ height: 40, resizeMethod: 'crop', defaultToInitialLetter: true - } + }; }, getInitialState: function() { diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index c8a9abb4fe..9fb522a5f1 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -42,7 +42,7 @@ module.exports = React.createClass({ height: 40, resizeMethod: 'crop', viewUserOnClick: false, - } + }; }, getInitialState: function() { @@ -64,7 +64,7 @@ module.exports = React.createClass({ props.width, props.height, props.resizeMethod) - } + }; }, render: function() { @@ -78,7 +78,7 @@ module.exports = React.createClass({ action: 'view_user', member: this.props.member, }); - } + }; } return ( diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index dcb25eff61..bfa7575b0c 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -39,7 +39,7 @@ module.exports = React.createClass({ height: 36, resizeMethod: 'crop', oobData: {}, - } + }; }, getInitialState: function() { @@ -51,7 +51,7 @@ module.exports = React.createClass({ componentWillReceiveProps: function(newProps) { this.setState({ urls: this.getImageUrls(newProps) - }) + }); }, getImageUrls: function(props) { diff --git a/src/components/views/create_room/Presets.js b/src/components/views/create_room/Presets.js index 0cce4a6644..6d40be9d32 100644 --- a/src/components/views/create_room/Presets.js +++ b/src/components/views/create_room/Presets.js @@ -40,7 +40,7 @@ module.exports = React.createClass({ }, onValueChanged: function(ev) { - this.props.onChange(ev.target.value) + this.props.onChange(ev.target.value); }, render: function() { diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index f6d7c17898..f8d93a3080 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -27,6 +27,15 @@ var Modal = require('../../../Modal'); 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: { @@ -71,15 +80,12 @@ module.exports = React.createClass({ }, onButtonClick: function() { - var inviteList = this.state.inviteList.slice(); + let inviteList = this.state.inviteList.slice(); // Check the text input field to see if user has an unconverted address // If there is and it's valid add it to the local inviteList - var check = Invite.isValidAddress(this.refs.textinput.value); - if (check === true || check === null) { - inviteList.push(this.refs.textinput.value); - } else if (this.refs.textinput.value.length > 0) { - this.setState({ error: true }); - return; + if (this.refs.textinput.value !== '') { + inviteList = this._addInputToList(); + if (inviteList === null) return; } if (inviteList.length > 0) { @@ -119,15 +125,15 @@ module.exports = React.createClass({ } else if (e.keyCode === 38) { // up arrow e.stopPropagation(); e.preventDefault(); - this.addressSelector.onKeyUp(); + this.addressSelector.moveSelectionUp(); } else if (e.keyCode === 40) { // down arrow e.stopPropagation(); e.preventDefault(); - this.addressSelector.onKeyDown(); - } else if (this.state.queryList.length > 0 && (e.keyCode === 188, e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab + this.addressSelector.moveSelectionDown(); + } else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab e.stopPropagation(); e.preventDefault(); - this.addressSelector.onKeySelect(); + this.addressSelector.chooseSelection(); } else if (this.refs.textinput.value.length === 0 && this.state.inviteList.length && e.keyCode === 8) { // backspace e.stopPropagation(); e.preventDefault(); @@ -135,21 +141,16 @@ module.exports = React.createClass({ } else if (e.keyCode === 13) { // enter e.stopPropagation(); e.preventDefault(); - this.onButtonClick(); + if (this.refs.textinput.value == '') { + // if there's nothing in the input box, submit the form + this.onButtonClick(); + } else { + this._addInputToList(); + } } else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab e.stopPropagation(); e.preventDefault(); - var check = Invite.isValidAddress(this.refs.textinput.value); - if (check === true || check === null) { - var inviteList = this.state.inviteList.slice(); - inviteList.push(this.refs.textinput.value.trim()); - this.setState({ - inviteList: inviteList, - queryList: [], - }); - } else { - this.setState({ error: true }); - } + this._addInputToList(); } }, @@ -191,7 +192,7 @@ module.exports = React.createClass({ inviteList: inviteList, queryList: [], }); - } + }; }, onClick: function(index) { @@ -327,13 +328,18 @@ 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; }, @@ -373,6 +379,22 @@ module.exports = React.createClass({ return addrs; }, + _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 { + this.setState({ error: true }); + return null; + } + }, + render: function() { var TintableSvg = sdk.getComponent("elements.TintableSvg"); var AddressSelector = sdk.getComponent("elements.AddressSelector"); @@ -406,13 +428,18 @@ module.exports = React.createClass({ var error; var addressSelector; if (this.state.error) { - error =
You have entered an invalid contact. Try using their Matrix ID or email address.
+ 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}} + {this.addressSelector = ref;}} addressList={ this.state.queryList } onSelected={ this.onSelected } - truncateAt={ TRUNCATE_QUERY_LIST } /> + truncateAt={ TRUNCATE_QUERY_LIST } + header={ addressSelectorHeader } + /> ); } diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index 926e4059d2..54a4e99424 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -80,8 +80,8 @@ export default class DeactivateAccountDialog extends React.Component { let error = null; if (this.state.errStr) { error =
- {this.state.err_str} -
+ {this.state.errStr} +
; passwordBoxClass = 'error'; } @@ -92,7 +92,7 @@ export default class DeactivateAccountDialog extends React.Component { if (!this.state.busy) { cancelButton = + ; } return ( diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 853b8db144..f9d1c2e28d 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -30,6 +30,9 @@ module.exports = React.createClass({ addressList: React.PropTypes.array.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() { @@ -57,7 +60,7 @@ module.exports = React.createClass({ } }, - onKeyUp: function() { + moveSelectionUp: function() { if (this.state.selected > 0) { this.setState({ selected: this.state.selected - 1, @@ -66,7 +69,7 @@ module.exports = React.createClass({ } }, - onKeyDown: function() { + moveSelectionDown: function() { if (this.state.selected < this._maxSelected(this.props.addressList)) { this.setState({ selected: this.state.selected + 1, @@ -75,25 +78,19 @@ module.exports = React.createClass({ } }, - onKeySelect: function() { + chooseSelection: function() { this.selectAddress(this.state.selected); }, onClick: function(index) { - var self = this; - return function() { - self.selectAddress(index); - }; + this.selectAddress(index); }, onMouseEnter: function(index) { - var self = this; - return function() { - self.setState({ - selected: index, - hover: true, - }); - }; + this.setState({ + selected: index, + hover: true, + }); }, onMouseLeave: function() { @@ -127,7 +124,7 @@ module.exports = React.createClass({ // method, how far to scroll when using the arrow keys addressList.push(
{ this.addressListElement = ref; }} > - +
); } @@ -137,7 +134,7 @@ module.exports = React.createClass({ _maxSelected: function(list) { var listSize = list.length === 0 ? 0 : list.length - 1; - var maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize + var maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize; return maxSelected; }, @@ -148,7 +145,8 @@ module.exports = React.createClass({ }); return ( -
{this.scrollElement = ref}}> +
{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 b49a84cedd..5f282afe93 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -111,7 +111,7 @@ module.exports = React.createClass({ info = (
{ this.props.address }
); - } else if (email) { + } else if (email) { var emailClasses = classNames({ "mx_AddressTile_email": true, "mx_AddressTile_justified": this.props.justified, diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js index aeb93e866c..da3975e4db 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -42,8 +42,8 @@ export default React.createClass({
  • { this.props.device.getDisplayName() }
  • -
  • { this.props.device.deviceId}
  • -
  • { this.props.device.getFingerprint() }
  • +
  • { this.props.device.deviceId}
  • +
  • { this.props.device.getFingerprint() }

diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 15118f249e..2c74567698 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -57,7 +57,7 @@ module.exports = React.createClass({ getInitialState: function() { return { phase: this.Phases.Display, - } + }; }, componentWillReceiveProps: function(nextProps) { @@ -164,7 +164,7 @@ module.exports = React.createClass({ this.setState({ phase: this.Phases.Edit, - }) + }); }, onFocus: function(ev) { @@ -197,9 +197,9 @@ module.exports = React.createClass({ sel.removeAllRanges(); if (this.props.blurToCancel) - this.cancelEdit(); + {this.cancelEdit();} else - this.onFinish(ev); + {this.onFinish(ev);} this.showPlaceholder(!this.value); }, diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index 993f2b965a..c7bfd4eec1 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -73,7 +73,7 @@ module.exports = React.createClass({ getValue: function() { var value; if (this.refs.select) { - value = reverseRoles[ this.refs.select.value ]; + value = reverseRoles[this.refs.select.value]; if (this.refs.custom) { if (value === undefined) value = parseInt( this.refs.custom.value ); } @@ -86,10 +86,10 @@ module.exports = React.createClass({ if (this.state.custom) { var input; if (this.props.disabled) { - input = { this.props.value } + input = { this.props.value }; } else { - input = + input = ; } customPicker = of { input }; } @@ -115,7 +115,7 @@ module.exports = React.createClass({ - + ; } return ( diff --git a/src/components/views/elements/ProgressBar.js b/src/components/views/elements/ProgressBar.js index 12b34480f1..a39e8e48f9 100644 --- a/src/components/views/elements/ProgressBar.js +++ b/src/components/views/elements/ProgressBar.js @@ -35,4 +35,4 @@ module.exports = React.createClass({

); } -}); \ No newline at end of file +}); diff --git a/src/components/views/elements/TruncatedList.js b/src/components/views/elements/TruncatedList.js index 3e174848d3..0ec2c15f0a 100644 --- a/src/components/views/elements/TruncatedList.js +++ b/src/components/views/elements/TruncatedList.js @@ -55,7 +55,7 @@ module.exports = React.createClass({ overflowJsx = this.props.createOverflowElement( overflowCount, childCount ); - + // cut out the overflow elements childArray.splice(childCount - overflowCount, overflowCount); childsJsx = childArray; // use what is left diff --git a/src/components/views/elements/UserSelector.js b/src/components/views/elements/UserSelector.js index 5f176a3e54..266e10154f 100644 --- a/src/components/views/elements/UserSelector.js +++ b/src/components/views/elements/UserSelector.js @@ -56,7 +56,7 @@ module.exports = React.createClass({
    {this.props.selected_users.map(function(user_id, i) { - return
  • {user_id} - X
  • + return
  • {user_id} - X
  • ; })}
diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index d50e0dee26..0977f947aa 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -52,7 +52,7 @@ module.exports = React.createClass({ this._onCaptchaLoaded(); } else { console.log("Loading recaptcha script..."); - window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()}; + window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();}; var protocol = global.location.protocol; if (protocol === "file:") { var warning = document.createElement('div'); @@ -101,7 +101,7 @@ module.exports = React.createClass({ } catch (e) { this.setState({ errorText: e.toString(), - }) + }); } }, diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index 23e2b442ef..ec184ca09f 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -70,7 +70,7 @@ export const PasswordAuthEntry = React.createClass({ }); }, - _onPasswordFieldChange: function (ev) { + _onPasswordFieldChange: function(ev) { // enable the submit button iff the password is non-empty this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); }, @@ -209,4 +209,4 @@ export function getEntryComponentForLoginType(loginType) { } } return FallbackAuthEntry; -}; +} diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 33809fbfd6..f8a0863f70 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -38,6 +38,16 @@ module.exports = React.createClass({ defaultEmail: React.PropTypes.string, defaultUsername: React.PropTypes.string, defaultPassword: React.PropTypes.string, + teamsConfig: React.PropTypes.shape({ + // Email address to request new teams + supportEmail: React.PropTypes.string, + teams: React.PropTypes.arrayOf(React.PropTypes.shape({ + // The displayed name of the team + "name": React.PropTypes.string, + // The suffix with which every team email address ends + "emailSuffix": React.PropTypes.string, + })).required, + }), // A username that will be used if no username is entered. // Specifying this param will also warn the user that entering @@ -62,7 +72,8 @@ module.exports = React.createClass({ getInitialState: function() { return { - fieldValid: {} + fieldValid: {}, + selectedTeam: null, }; }, @@ -105,10 +116,14 @@ module.exports = React.createClass({ }, _doSubmit: function() { + let email = this.refs.email.value.trim(); + if (this.state.selectedTeam) { + email += "@" + this.state.selectedTeam.emailSuffix; + } var promise = this.props.onRegisterClick({ username: this.refs.username.value.trim() || this.props.guestUsername, password: this.refs.password.value.trim(), - email: this.refs.email.value.trim() + email: email, }); if (promise) { @@ -119,6 +134,25 @@ module.exports = React.createClass({ } }, + onSelectTeam: function(teamIndex) { + let team = this._getSelectedTeam(teamIndex); + if (team) { + this.refs.email.value = this.refs.email.value.split("@")[0]; + } + this.setState({ + selectedTeam: team, + showSupportEmail: teamIndex === "other", + }); + }, + + _getSelectedTeam: function(teamIndex) { + if (this.props.teamsConfig && + this.props.teamsConfig.teams[teamIndex]) { + return this.props.teamsConfig.teams[teamIndex]; + } + return null; + }, + /** * Returns true if all fields were valid last time * they were validated. @@ -135,15 +169,19 @@ module.exports = React.createClass({ validateField: function(field_id) { var pwd1 = this.refs.password.value.trim(); - var pwd2 = this.refs.passwordConfirm.value.trim() + var pwd2 = this.refs.passwordConfirm.value.trim(); switch (field_id) { case FIELD_EMAIL: - this.markFieldValid( - field_id, - this.refs.email.value == '' || Email.looksValid(this.refs.email.value), - "RegistrationForm.ERR_EMAIL_INVALID" - ); + let email = this.refs.email.value; + if (this.props.teamsConfig) { + let team = this.state.selectedTeam; + if (team) { + email = email + "@" + team.emailSuffix; + } + } + let valid = email === '' || Email.looksValid(email); + this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID"); break; case FIELD_USERNAME: // XXX: SPEC-1 @@ -222,17 +260,64 @@ module.exports = React.createClass({ return cls; }, + _renderEmailInputSuffix: function() { + let suffix = null; + if (!this.state.selectedTeam) { + return suffix; + } + let team = this.state.selectedTeam; + if (team) { + suffix = "@" + team.emailSuffix; + } + return suffix; + }, + render: function() { var self = this; - var emailSection, registerButton; + var emailSection, teamSection, teamAdditionSupport, registerButton; if (this.props.showEmail) { + let emailSuffix = this._renderEmailInputSuffix(); emailSection = ( - +
+ + {emailSuffix ? : null } +
); + if (this.props.teamsConfig) { + teamSection = ( + + ); + if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { + teamAdditionSupport = ( + + If your team is not listed, email  + + {this.props.teamsConfig.supportEmail} + + + ); + } + } } if (this.props.onRegisterClick) { registerButton = ( @@ -242,31 +327,34 @@ module.exports = React.createClass({ var placeholderUserName = "User name"; if (this.props.guestUsername) { - placeholderUserName += " (default: " + this.props.guestUsername + ")" + placeholderUserName += " (default: " + this.props.guestUsername + ")"; } return (
+ {teamSection} + {teamAdditionSupport} +
{emailSection}
+ onBlur={function() {self.validateField(FIELD_USERNAME);}} />
{ this.props.guestUsername ?
Setting a user name will create a fresh account
: null }

{registerButton} diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index a18cfbbcef..4e6ed12f9e 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -64,10 +64,10 @@ module.exports = React.createClass({ hs_url: this.props.customHsUrl, is_url: this.props.customIsUrl, // if withToggleButton is false, then show the config all the time given we have no way otherwise of making it visible - configVisible: !this.props.withToggleButton || + configVisible: !this.props.withToggleButton || (this.props.customHsUrl !== this.props.defaultHsUrl) || (this.props.customIsUrl !== this.props.defaultIsUrl) - } + }; }, onHomeserverChanged: function(ev) { diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index 7e338e8466..73b9bdb200 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -31,7 +31,7 @@ export default class MAudioBody extends React.Component { decryptedUrl: null, decryptedBlob: null, error: null, - } + }; } onPlayToggle() { this.setState({ diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index 4f5ca2d3be..86aee28269 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -281,7 +281,7 @@ module.exports = React.createClass({ decryptedBlob: blob, }); }).catch((err) => { - console.warn("Unable to decrypt attachment: ", err) + console.warn("Unable to decrypt attachment: ", err); Modal.createDialog(ErrorDialog, { description: "Error decrypting attachment" }); @@ -372,7 +372,7 @@ module.exports = React.createClass({ var extra = text ? (': ' + text) : ''; return Invalid file{extra} - + ; } }, }); diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 5e7cb6e800..10941e0f7f 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -119,7 +119,7 @@ module.exports = React.createClass({ if (content.info.thumbnail_file) { thumbnailPromise = decryptFile( content.info.thumbnail_file - ).then(function (blob) { + ).then(function(blob) { return readBlobAsDataUri(blob); }); } diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 18552a973d..e2d4af9e69 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -111,7 +111,7 @@ module.exports = React.createClass({ this.props.onWidgetLoad(); }); }).catch((err) => { - console.warn("Unable to decrypt attachment: ", err) + console.warn("Unable to decrypt attachment: ", err); // Set a placeholder image when we can't decrypt the image. this.setState({ error: err, diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index d005ef0cca..fd26ae58da 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -200,7 +200,7 @@ module.exports = React.createClass({ global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId()); } }, - } + }; }, onStarterLinkClick: function(starterLink, ev) { @@ -230,8 +230,8 @@ module.exports = React.createClass({ if (!confirmed) { return; } - let width = window.screen.width > 1024 ? 1024 : window.screen.width; - let height = window.screen.height > 800 ? 800 : window.screen.height; + let width = window.screen.width > 1024 ? 1024 : window.screen.width; + let height = window.screen.height > 800 ? 800 : window.screen.height; let left = (window.screen.width - width) / 2; let top = (window.screen.height - height) / 2; window.open(completeUrl, '_blank', `height=${height}, width=${width}, top=${top}, left=${left},`); diff --git a/src/components/views/room_settings/AliasSettings.js b/src/components/views/room_settings/AliasSettings.js index 96618b613a..6543f2a17d 100644 --- a/src/components/views/room_settings/AliasSettings.js +++ b/src/components/views/room_settings/AliasSettings.js @@ -103,13 +103,13 @@ module.exports = React.createClass({ } if (oldCanonicalAlias !== this.state.canonicalAlias) { console.log("AliasSettings: Updating canonical alias"); - promises = [ q.all(promises).then( + promises = [q.all(promises).then( MatrixClientPeg.get().sendStateEvent( this.props.roomId, "m.room.canonical_alias", { alias: this.state.canonicalAlias }, "" ) - ) ]; + )]; } return promises; @@ -144,7 +144,7 @@ module.exports = React.createClass({ // XXX: do we need to deep copy aliases before editing it? this.state.domainToAliases[domain] = this.state.domainToAliases[domain] || []; this.state.domainToAliases[domain].push(alias); - this.setState({ + this.setState({ domainToAliases: this.state.domainToAliases }); @@ -152,9 +152,9 @@ module.exports = React.createClass({ this.refs.add_alias.setValue(''); // FIXME } else { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Invalid alias format", + title: "Invalid alias format", description: "'" + alias + "' is not a valid format for an alias", }); } @@ -168,9 +168,9 @@ module.exports = React.createClass({ this.state.domainToAliases[domain][index] = alias; } else { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { - title: "Invalid address format", + title: "Invalid address format", description: "'" + alias + "' is not a valid format for an address", }); } @@ -183,7 +183,7 @@ module.exports = React.createClass({ // would be to arbitrarily deepcopy to a temp variable and then setState // that, but why bother when we can cut this corner. var alias = this.state.domainToAliases[domain].splice(index, 1); - this.setState({ + this.setState({ domainToAliases: this.state.domainToAliases }); }, @@ -281,7 +281,7 @@ module.exports = React.createClass({ onValueChanged={ self.onAliasChanged.bind(self, localDomain, i) } editable={ self.props.canSetAliases } initialValue={ alias } /> -
+
{ deleteButton }
@@ -297,7 +297,7 @@ module.exports = React.createClass({ placeholder={ "New address (e.g. #foo:" + localDomain + ")" } blurToCancel={ false } onValueChanged={ self.onAliasAdded } /> -
+
Add
@@ -310,4 +310,4 @@ module.exports = React.createClass({
); } -}); \ No newline at end of file +}); diff --git a/src/components/views/room_settings/ColorSettings.js b/src/components/views/room_settings/ColorSettings.js index 6d147b1f63..6a455d9c3c 100644 --- a/src/components/views/room_settings/ColorSettings.js +++ b/src/components/views/room_settings/ColorSettings.js @@ -135,7 +135,7 @@ module.exports = React.createClass({
); } - var boundClick = this._onColorSchemeChanged.bind(this, i) + var boundClick = this._onColorSchemeChanged.bind(this, i); return (
Disable URL previews by default for participants in this room - + ; } else { disableRoomPreviewUrls = + ; } return ( @@ -154,4 +154,4 @@ module.exports = React.createClass({ ); } -}); \ No newline at end of file +}); diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js index f7c3052ea8..365cc18f99 100644 --- a/src/components/views/rooms/AuxPanel.js +++ b/src/components/views/rooms/AuxPanel.js @@ -93,8 +93,8 @@ module.exports = React.createClass({ } else { joinText = ( - Join as { this.onConferenceNotificationClick(event, 'voice')}} - href="#">voice or { this.onConferenceNotificationClick(event, 'video') }} + Join as { this.onConferenceNotificationClick(event, 'voice');}} + href="#">voice or { this.onConferenceNotificationClick(event, 'video'); }} href="#">video. ); diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 42dbe78630..c6a766509a 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -149,13 +149,13 @@ module.exports = WithMatrixClient(React.createClass({ this.props.mxEvent.on("Event.decrypted", this._onDecrypted); }, - componentWillReceiveProps: function (nextProps) { + componentWillReceiveProps: function(nextProps) { if (nextProps.mxEvent !== this.props.mxEvent) { this._verifyEvent(nextProps.mxEvent); } }, - shouldComponentUpdate: function (nextProps, nextState) { + shouldComponentUpdate: function(nextProps, nextState) { if (!ObjectUtils.shallowEqual(this.state, nextState)) { return true; } @@ -259,11 +259,11 @@ module.exports = WithMatrixClient(React.createClass({ onEditClicked: function(e) { var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); - var buttonRect = e.target.getBoundingClientRect() + var buttonRect = e.target.getBoundingClientRect(); // The window X and Y offsets are to adjust position when zoomed in to page var x = buttonRect.right + window.pageXOffset; - var y = (buttonRect.top + (e.target.height / 2) + window.pageYOffset) - 19; + var y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; var self = this; ContextualMenu.createMenu(MessageContextMenu, { chevronOffset: 10, @@ -293,7 +293,7 @@ module.exports = WithMatrixClient(React.createClass({ // If it is, we want to display the complete date along with the HH:MM:SS, // rather than just HH:MM:SS. let dayAfterEvent = new Date(this.props.mxEvent.getTs()); - dayAfterEvent.setDate(dayAfterEvent.getDate() + 1) + dayAfterEvent.setDate(dayAfterEvent.getDate() + 1); dayAfterEvent.setHours(0); dayAfterEvent.setMinutes(0); dayAfterEvent.setSeconds(0); @@ -366,10 +366,11 @@ module.exports = WithMatrixClient(React.createClass({ }, onCryptoClicked: function(e) { - var EncryptedEventDialog = sdk.getComponent("dialogs.EncryptedEventDialog"); var event = this.props.mxEvent; - Modal.createDialog(EncryptedEventDialog, { + Modal.createDialogAsync((cb) => { + require(['../../../async-components/views/dialogs/EncryptedEventDialog'], cb); + }, { event: event, }); }, @@ -465,7 +466,7 @@ module.exports = WithMatrixClient(React.createClass({ } var editButton = ( - Options + ); var e2e; diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js index 60f4f8abc0..ef8fb29cbc 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.js @@ -60,13 +60,15 @@ module.exports = React.createClass({ }, componentDidMount: function() { - if (this.refs.description) + if (this.refs.description) { linkifyElement(this.refs.description, linkifyMatrix.options); + } }, componentDidUpdate: function() { - if (this.refs.description) + if (this.refs.description) { linkifyElement(this.refs.description, linkifyMatrix.options); + } }, componentWillUnmount: function() { @@ -116,7 +118,7 @@ module.exports = React.createClass({ if (image) { img =
-
+
; } return ( diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js index 1e7850ab44..d4c00dda76 100644 --- a/src/components/views/rooms/MemberDeviceInfo.js +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -60,7 +60,7 @@ export default class MemberDeviceInfo extends React.Component {
); } -}; +} MemberDeviceInfo.displayName = 'MemberDeviceInfo'; MemberDeviceInfo.propTypes = { diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 1f4d392461..16a047f72d 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -64,7 +64,7 @@ module.exports = WithMatrixClient(React.createClass({ updating: 0, devicesLoading: true, devices: null, - } + }; }, componentWillMount: function() { @@ -202,7 +202,7 @@ module.exports = WithMatrixClient(React.createClass({ } var cancelled = false; - this._cancelDeviceList = function() { cancelled = true; } + this._cancelDeviceList = function() { cancelled = true; }; var client = this.props.matrixClient; var self = this; @@ -529,7 +529,7 @@ module.exports = WithMatrixClient(React.createClass({ }); }, - onMemberAvatarClick: function () { + onMemberAvatarClick: function() { var avatarUrl = this.props.member.user ? this.props.member.user.avatarUrl : this.props.member.events.member.getContent().avatar_url; if(!avatarUrl) return; @@ -620,7 +620,7 @@ module.exports = WithMatrixClient(React.createClass({
Start new chat
-
+
; startChat =

Direct chats

@@ -654,7 +654,7 @@ module.exports = WithMatrixClient(React.createClass({ var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator"; 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 @@ -672,7 +672,7 @@ module.exports = WithMatrixClient(React.createClass({ {banButton} {giveModButton} - + ; } const memberName = this.props.member.name; diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index deedded4fa..bd386ed1bb 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -32,7 +32,7 @@ var SHARE_HISTORY_WARNING = Newly invited users will see the history of this room.
If you'd prefer invited users not to see messages that were sent before they joined,
turn off, 'Share message history with new users' in the settings for this room. - + ; module.exports = React.createClass({ displayName: 'MemberList', @@ -207,7 +207,7 @@ module.exports = React.createClass({ // For now we'll pretend this is any entity. It should probably be a separate tile. var EntityTile = sdk.getComponent("rooms.EntityTile"); var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "..."; + var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "..."; return ( @@ -338,8 +338,8 @@ module.exports = React.createClass({ } memberList.push( - ) - }) + ); + }); } } diff --git a/src/components/views/rooms/MemberTile.js b/src/components/views/rooms/MemberTile.js index cf79394228..5becef9ede 100644 --- a/src/components/views/rooms/MemberTile.js +++ b/src/components/views/rooms/MemberTile.js @@ -46,7 +46,7 @@ module.exports = React.createClass({ (this.user_last_modified_time === undefined || this.user_last_modified_time < nextProps.member.user.getLastModifiedTime()) ) { - return true + return true; } return false; }, diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index ee9c49d52a..113224666d 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -222,20 +222,22 @@ export default class MessageComposer extends React.Component { ); - let e2eimg, e2etitle; + let e2eImg, e2eTitle, e2eClass; if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) { // FIXME: show a /!\ if there are untrusted devices in the room... - e2eimg = 'img/e2e-verified.svg'; - e2etitle = 'Encrypted room'; + e2eImg = 'img/e2e-verified.svg'; + e2eTitle = 'Encrypted room'; + e2eClass = 'mx_MessageComposer_e2eIcon'; } else { - e2eimg = 'img/e2e-unencrypted.svg'; - e2etitle = 'Unencrypted room'; + e2eImg = 'img/e2e-unencrypted.svg'; + e2eTitle = 'Unencrypted room'; + e2eClass = 'mx_MessageComposer_e2eIcon mx_filterFlipColor'; } controls.push( - {e2etitle} ); var callButton, videoCallButton, hangupButton; @@ -331,6 +333,7 @@ export default class MessageComposer extends React.Component { const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; const className = classNames("mx_MessageComposer_format_button", { mx_MessageComposer_format_button_disabled: disabled, + mx_filterFlipColor: true, }); return : null @@ -367,7 +370,7 @@ export default class MessageComposer extends React.Component { ); } -}; +} MessageComposer.propTypes = { tabComplete: React.PropTypes.any, diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 37d937d6f5..bf936a2c13 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -443,12 +443,12 @@ export default class MessageComposerInput extends React.Component { selection = this.state.editorState.getSelection(); let modifyFn = { - bold: text => `**${text}**`, - italic: text => `*${text}*`, - underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* - strike: text => `~~${text}~~`, - code: text => `\`${text}\``, - blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''), + 'bold': text => `**${text}**`, + 'italic': text => `*${text}*`, + 'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* + 'strike': text => `~~${text}~~`, + 'code': text => `\`${text}\``, + 'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''), 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), }[command]; @@ -462,8 +462,9 @@ export default class MessageComposerInput extends React.Component { } } - if (newState == null) + if (newState == null) { newState = RichUtils.handleKeyCommand(this.state.editorState, command); + } if (newState != null) { this.setEditorState(newState); @@ -523,7 +524,9 @@ export default class MessageComposerInput extends React.Component { ); } else { const md = new Markdown(contentText); - if (!md.isPlainText()) { + if (md.isPlainText()) { + contentText = md.toPlaintext(); + } else { contentHTML = md.toHTML(); } } @@ -663,7 +666,7 @@ export default class MessageComposerInput extends React.Component { const blockName = { 'code-block': 'code', - blockquote: 'quote', + 'blockquote': 'quote', 'unordered-list-item': 'bullet', 'ordered-list-item': 'numbullet', }; @@ -716,7 +719,7 @@ export default class MessageComposerInput extends React.Component { selection={selection} />
- @@ -738,7 +741,7 @@ export default class MessageComposerInput extends React.Component {
); } -}; +} MessageComposerInput.propTypes = { tabComplete: React.PropTypes.any, diff --git a/src/components/views/rooms/MessageComposerInputOld.js b/src/components/views/rooms/MessageComposerInputOld.js index 28e3186c50..c5d5f083c1 100644 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ b/src/components/views/rooms/MessageComposerInputOld.js @@ -192,7 +192,7 @@ module.exports = React.createClass({ } }, - onKeyDown: function (ev) { + onKeyDown: function(ev) { if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) { var input = this.refs.textarea.value; if (input.length === 0) { @@ -331,6 +331,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); } else { + const contentText = mdown.toPlaintext(); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js index 47875bd7fb..c1fe4431bf 100644 --- a/src/components/views/rooms/ReadReceiptMarker.js +++ b/src/components/views/rooms/ReadReceiptMarker.js @@ -71,7 +71,7 @@ module.exports = React.createClass({ getDefaultProps: function() { return { leftOffset: 0, - } + }; }, getInitialState: function() { @@ -81,7 +81,7 @@ module.exports = React.createClass({ // position. return { suppressDisplay: !this.props.suppressAnimation, - } + }; }, componentWillUnmount: function() { diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index db3c7bb3d9..e345918f07 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -182,8 +182,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) { @@ -193,7 +193,7 @@ module.exports = React.createClass({ if (can_set_room_name) { var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor"); - name = + name = ; } else { var searchStatus; @@ -232,7 +232,7 @@ module.exports = React.createClass({ if (can_set_room_topic) { var RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor"); - topic_el = + topic_el = ; } else { var topic; if (this.props.room) { @@ -301,7 +301,7 @@ module.exports = React.createClass({ rightPanel_buttons =
-
+ ; } var right_row; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 3ced4102d4..c3ee5f1730 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -46,7 +46,7 @@ module.exports = React.createClass({ isLoadingLeftRooms: false, lists: {}, incomingCall: null, - } + }; }, componentWillMount: function() { @@ -338,7 +338,7 @@ module.exports = React.createClass({ // as this is used to calculate the CSS fixed top position for the stickies var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; - var top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset) + var top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset); // Make sure we don't go too far up, if the headers aren't sticky top = (top < scrollAreaOffset) ? scrollAreaOffset : top; // make sure we don't go too far down, if the headers aren't sticky @@ -401,7 +401,7 @@ module.exports = React.createClass({ var stickyHeight = sticky.dataset.originalHeight; var stickyHeader = sticky.childNodes[0]; var topStuckHeight = stickyHeight * i; - var bottomStuckHeight = stickyHeight * (stickyWrappers.length - i) + var bottomStuckHeight = stickyHeight * (stickyWrappers.length - i); if (self.scrollAreaSufficient && stickyPosition < (scrollArea.scrollTop + topStuckHeight)) { // Top stickies @@ -520,7 +520,7 @@ module.exports = React.createClass({ collapsed={ self.props.collapsed } searchFilter={ self.props.searchFilter } onHeaderClick={ self.onSubListHeaderClick } - onShowMoreRooms={ self.onShowMoreRooms } /> + onShowMoreRooms={ self.onShowMoreRooms } />; } }) } diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index b9912b6fcc..218bac48aa 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -58,7 +58,7 @@ module.exports = React.createClass({ getInitialState: function() { return { busy: false - } + }; }, componentWillMount: function() { @@ -96,7 +96,7 @@ module.exports = React.createClass({ emailMatchBlock =
Unable to ascertain that the address this invite was sent to matches one associated with your account. -
+ ; } else if (this.state.invitedEmailMxid != MatrixClientPeg.get().credentials.userId) { emailMatchBlock =
@@ -107,7 +107,7 @@ module.exports = React.createClass({ This invitation was sent to {this.props.invitedEmail}, which is not associated with this account.
You may wish to login with a different account, or add this email to this account.
- + ; } } joinBlock = ( diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 04ea05843d..e14a929ebe 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -252,7 +252,7 @@ module.exports = React.createClass({ return this.refs.url_preview_settings.saveSettings(); }, - saveEncryption: function () { + saveEncryption: function() { if (!this.refs.encrypt) { return q(); } var encrypt = this.refs.encrypt.checked; @@ -404,7 +404,7 @@ module.exports = React.createClass({ var cli = MatrixClientPeg.get(); var roomState = this.props.room.currentState; return (roomState.mayClientSendStateEvent("m.room.join_rules", cli) && - roomState.mayClientSendStateEvent("m.room.guest_access", cli)) + roomState.mayClientSendStateEvent("m.room.guest_access", cli)); }, onManageIntegrations(ev) { @@ -510,7 +510,7 @@ module.exports = React.createClass({ var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings"); var EditableText = sdk.getComponent('elements.EditableText'); var PowerSelector = sdk.getComponent('elements.PowerSelector'); - var Loader = sdk.getComponent("elements.Spinner") + var Loader = sdk.getComponent("elements.Spinner"); var cli = MatrixClientPeg.get(); var roomState = this.props.room.currentState; @@ -557,7 +557,7 @@ module.exports = React.createClass({ ; } else { - userLevelsSection =
No users have specific privileges in this room.
+ userLevelsSection =
No users have specific privileges in this room.
; } var banned = this.props.room.getMembersWithMembership("ban"); @@ -635,7 +635,7 @@ module.exports = React.createClass({ ); })) : (self.state.tags && self.state.tags.join) ? self.state.tags.join(", ") : "" } - + ; } // If there is no history_visibility, it is assumed to be 'shared'. @@ -653,7 +653,7 @@ module.exports = React.createClass({ addressWarning =
To link to a room it must have an address. -
+ ; } var inviteGuestWarning; @@ -664,7 +664,7 @@ module.exports = React.createClass({ this.setState({ join_rule: "invite", guest_access: "can_join" }); e.preventDefault(); }}>Click here to fix. - + ; } var integrationsButton; diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 84916f8ab8..83b9cf3c6f 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'); +var UserSettingsStore = require('../../../UserSettingsStore'); module.exports = React.createClass({ displayName: 'RoomTile', @@ -176,7 +177,8 @@ module.exports = React.createClass({ var self = this; ContextualMenu.createMenu(RoomTagMenu, { chevronOffset: 10, - menuColour: "#FFFFFF", + // XXX: fix horrid hardcoding + menuColour: UserSettingsStore.getSyncedSettings().theme === 'dark' ? "#2d2d2d" : "#FFFFFF", left: x, top: y, room: this.props.room, @@ -219,7 +221,7 @@ module.exports = React.createClass({ var avatarContainerClasses = classNames({ 'mx_RoomTile_avatar_container': true, 'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu, - }) + }); var badgeClasses = classNames({ 'mx_RoomTile_badge': true, diff --git a/src/components/views/rooms/SearchableEntityList.js b/src/components/views/rooms/SearchableEntityList.js index ccf733b985..50169edad5 100644 --- a/src/components/views/rooms/SearchableEntityList.js +++ b/src/components/views/rooms/SearchableEntityList.js @@ -118,7 +118,7 @@ var SearchableEntityList = React.createClass({ _createOverflowEntity: function(overflowCount, totalCount) { var EntityTile = sdk.getComponent("rooms.EntityTile"); var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "..."; + var text = "and " + overflowCount + " other" + (overflowCount > 1 ? "s" : "") + "..."; return ( @@ -135,8 +135,8 @@ var SearchableEntityList = React.createClass({ { this.setState({ focused: true }) }} - onBlur= {() => { this.setState({ focused: false }) }} + onFocus= {() => { this.setState({ focused: true }); }} + onBlur= {() => { this.setState({ focused: false }); }} placeholder={this.props.searchPlaceholderText} /> ); diff --git a/src/components/views/rooms/SimpleRoomHeader.js b/src/components/views/rooms/SimpleRoomHeader.js index 7f2bb0048a..4c63be5b99 100644 --- a/src/components/views/rooms/SimpleRoomHeader.js +++ b/src/components/views/rooms/SimpleRoomHeader.js @@ -44,7 +44,7 @@ module.exports = React.createClass({ var cancelButton; if (this.props.onCancelClick) { - cancelButton =
Cancel
+ cancelButton =
Cancel
; } var showRhsButton; diff --git a/src/components/views/rooms/UserTile.js b/src/components/views/rooms/UserTile.js index 9608247d5e..66d736f3bb 100644 --- a/src/components/views/rooms/UserTile.js +++ b/src/components/views/rooms/UserTile.js @@ -38,7 +38,7 @@ module.exports = React.createClass({ var active = -1; // FIXME: make presence data update whenever User.presence changes... - active = user.lastActiveAgo ? + active = user.lastActiveAgo ? (Date.now() - (user.lastPresenceTs - user.lastActiveAgo)) : -1; var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); diff --git a/src/components/views/settings/ChangeAvatar.js b/src/components/views/settings/ChangeAvatar.js index 9b03aba1a3..de30b51f1b 100644 --- a/src/components/views/settings/ChangeAvatar.js +++ b/src/components/views/settings/ChangeAvatar.js @@ -49,7 +49,7 @@ module.exports = React.createClass({ return { avatarUrl: this.props.initialAvatarUrl, phase: this.Phases.Display, - } + }; }, componentWillReceiveProps: function(newProps) { @@ -120,7 +120,7 @@ module.exports = React.createClass({ var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); // XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ? avatarImg = + name='?' idName={ MatrixClientPeg.get().getUserIdLocalpart() } url={this.state.avatarUrl} />; } var uploadSection; @@ -130,7 +130,7 @@ module.exports = React.createClass({ Upload new: {this.state.errorText} - + ); } diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index 1ef3eff205..a011d5262e 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -59,7 +59,7 @@ module.exports = React.createClass({ getInitialState: function() { return { phase: this.Phases.Edit - } + }; }, changePassword: function(old_password, new_password) { @@ -105,7 +105,7 @@ module.exports = React.createClass({ render: function() { var rowClassName = this.props.rowClassName; var rowLabelClassName = this.props.rowLabelClassName; - var rowInputClassName = this.props.rowInputClassName + var rowInputClassName = this.props.rowInputClassName; var buttonClassName = this.props.buttonClassName; switch (this.state.phase) { diff --git a/src/components/views/settings/DevicesPanel.js b/src/components/views/settings/DevicesPanel.js index f48d4bec85..28eee55527 100644 --- a/src/components/views/settings/DevicesPanel.js +++ b/src/components/views/settings/DevicesPanel.js @@ -88,7 +88,7 @@ export default class DevicesPanel extends React.Component { const removed_id = device.device_id; this.setState((state, props) => { const newDevices = state.devices.filter( - d => { return d.device_id != removed_id } + d => { return d.device_id != removed_id; } ); return { devices: newDevices }; }); @@ -98,7 +98,7 @@ export default class DevicesPanel extends React.Component { var DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry'); return ( {this._onDeviceDeleted(device)}} /> + onDeleted={()=>{this._onDeviceDeleted(device);}} /> ); } diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js index f9f0e49a5e..4fa7d961ac 100644 --- a/src/components/views/settings/DevicesPanelEntry.js +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -15,12 +15,9 @@ limitations under the License. */ import React from 'react'; -import classNames from 'classnames'; -import q from 'q'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import DateUtils from '../../../DateUtils'; import Modal from '../../../Modal'; export default class DevicesPanelEntry extends React.Component { @@ -61,7 +58,7 @@ export default class DevicesPanelEntry extends React.Component { if (this._unmounted) { return; } if (error.httpStatus !== 401 || !error.data || !error.data.flows) { // doesn't look like an interactive-auth failure - throw e; + throw error; } // pop up an interactive auth dialog @@ -121,7 +118,7 @@ export default class DevicesPanelEntry extends React.Component { let deleteButton; if (this.state.deleteError) { - deleteButton =
{this.state.deleteError}
+ deleteButton =
{this.state.deleteError}
; } else { deleteButton = (
-
+
diff --git a/src/createRoom.js b/src/createRoom.js index a1512e23f6..2a23fb0787 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -45,7 +45,7 @@ function createRoom(opts) { Modal.createDialog(NeedToRegisterDialog, { title: "Please Register", description: "Guest users can't create new rooms. Please register to create room and start a chat." - }) + }); }, 0); return q(null); } @@ -78,7 +78,7 @@ function createRoom(opts) { let modal; setTimeout(()=>{ - modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner') + modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); }, 0); let roomId; diff --git a/src/dispatcher.js b/src/dispatcher.js index f35639c3be..ed0350fe54 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -28,6 +28,7 @@ class MatrixDispatcher extends flux.Dispatcher { * for. */ dispatch(payload, sync) { + console.log("Dispatch: "+payload.action); if (sync) { super.dispatch(payload); } else { @@ -39,7 +40,7 @@ class MatrixDispatcher extends flux.Dispatcher { setTimeout(super.dispatch.bind(this, payload), 0); } } -}; +} if (global.mxDispatcher === undefined) { global.mxDispatcher = new MatrixDispatcher(); diff --git a/src/extend.js b/src/extend.js index e39e9e9be9..cc3c33b2e7 100644 --- a/src/extend.js +++ b/src/extend.js @@ -23,4 +23,4 @@ module.exports = function(dest, src) { } } return dest; -} +}; diff --git a/src/index.js b/src/index.js index 4b920d95d4..5d4145a39b 100644 --- a/src/index.js +++ b/src/index.js @@ -28,3 +28,27 @@ 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/src/linkify-matrix.js b/src/linkify-matrix.js index e7d798c9ff..68f7a66bda 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -129,7 +129,7 @@ matrixLinkify.MATRIXTO_URL_PATTERN = "^(?:https?:\/\/)?(?:www\\.)?matrix\\.to/#/ matrixLinkify.MATRIXTO_BASE_URL= "https://matrix.to"; matrixLinkify.options = { - events: function (href, type) { + events: function(href, type) { switch (type) { case "userid": return { @@ -146,7 +146,7 @@ matrixLinkify.options = { } }, - formatHref: function (href, type) { + formatHref: function(href, type) { switch (type) { case 'roomalias': case 'userid': diff --git a/src/utils/FormattingUtils.js b/src/utils/FormattingUtils.js index 44dcb2aa22..414784d101 100644 --- a/src/utils/FormattingUtils.js +++ b/src/utils/FormattingUtils.js @@ -25,4 +25,4 @@ export function formatCount(count) { if (count < 10000000) return (count / 1000000).toFixed(1) + "M"; if (count < 100000000) return (count / 1000000).toFixed(0) + "M"; return (count / 1000000000).toFixed(1) + "B"; // 10B is enough for anyone, right? :S -} \ No newline at end of file +} diff --git a/src/utils/MegolmExportEncryption.js b/src/utils/MegolmExportEncryption.js new file mode 100644 index 0000000000..abae81e5ad --- /dev/null +++ b/src/utils/MegolmExportEncryption.js @@ -0,0 +1,319 @@ +/* +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. +*/ + +"use strict"; + +// polyfill textencoder if necessary +import * as TextEncodingUtf8 from 'text-encoding-utf-8'; +let TextEncoder = window.TextEncoder; +if (!TextEncoder) { + TextEncoder = TextEncodingUtf8.TextEncoder; +} +let TextDecoder = window.TextDecoder; +if (!TextDecoder) { + TextDecoder = TextEncodingUtf8.TextDecoder; +} + +const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle; + +/** + * Decrypt a megolm key file + * + * @param {ArrayBuffer} file + * @param {String} password + * @return {Promise} promise for decrypted output + */ +export function decryptMegolmKeyFile(data, password) { + const body = unpackMegolmKeyFile(data); + + // check we have a version byte + if (body.length < 1) { + throw new Error('Invalid file: too short'); + } + + const version = body[0]; + if (version !== 1) { + throw new Error('Unsupported version'); + } + + const ciphertextLength = body.length-(1+16+16+4+32); + if (body.length < 0) { + throw new Error('Invalid file: too short'); + } + + const salt = body.subarray(1, 1+16); + const iv = body.subarray(17, 17+16); + const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36]; + const ciphertext = body.subarray(37, 37+ciphertextLength); + const hmac = body.subarray(-32); + + return deriveKeys(salt, iterations, password).then((keys) => { + const [aes_key, hmac_key] = keys; + + const toVerify = body.subarray(0, -32); + return subtleCrypto.verify( + {name: 'HMAC'}, + hmac_key, + hmac, + toVerify, + ).then((isValid) => { + if (!isValid) { + throw new Error('Authentication check failed: incorrect password?'); + } + + return subtleCrypto.decrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aes_key, + ciphertext, + ); + }); + }).then((plaintext) => { + return new TextDecoder().decode(new Uint8Array(plaintext)); + }); +} + + +/** + * Encrypt a megolm key file + * + * @param {String} data + * @param {String} password + * @param {Object=} options + * @param {Nunber=} options.kdf_rounds Number of iterations to perform of the + * key-derivation function. + * @return {Promise} promise for encrypted output + */ +export function encryptMegolmKeyFile(data, password, options) { + options = options || {}; + const kdf_rounds = options.kdf_rounds || 100000; + + const salt = new Uint8Array(16); + window.crypto.getRandomValues(salt); + + // clear bit 63 of the salt to stop us hitting the 64-bit counter boundary + // (which would mean we wouldn't be able to decrypt on Android). The loss + // of a single bit of salt is a price we have to pay. + salt[9] &= 0x7f; + + const iv = new Uint8Array(16); + window.crypto.getRandomValues(iv); + + return deriveKeys(salt, kdf_rounds, password).then((keys) => { + const [aes_key, hmac_key] = keys; + + return subtleCrypto.encrypt( + { + name: "AES-CTR", + counter: iv, + length: 64, + }, + aes_key, + new TextEncoder().encode(data), + ).then((ciphertext) => { + const cipherArray = new Uint8Array(ciphertext); + const bodyLength = (1+salt.length+iv.length+4+cipherArray.length+32); + const resultBuffer = new Uint8Array(bodyLength); + let idx = 0; + resultBuffer[idx++] = 1; // version + resultBuffer.set(salt, idx); idx += salt.length; + resultBuffer.set(iv, idx); idx += iv.length; + resultBuffer[idx++] = kdf_rounds >> 24; + resultBuffer[idx++] = (kdf_rounds >> 16) & 0xff; + resultBuffer[idx++] = (kdf_rounds >> 8) & 0xff; + resultBuffer[idx++] = kdf_rounds & 0xff; + resultBuffer.set(cipherArray, idx); idx += cipherArray.length; + + const toSign = resultBuffer.subarray(0, idx); + + return subtleCrypto.sign( + {name: 'HMAC'}, + hmac_key, + toSign, + ).then((hmac) => { + hmac = new Uint8Array(hmac); + resultBuffer.set(hmac, idx); + return packMegolmKeyFile(resultBuffer); + }); + }); + }); +} + +/** + * Derive the AES and HMAC-SHA-256 keys for the file + * + * @param {Unit8Array} salt salt for pbkdf + * @param {Number} iterations number of pbkdf iterations + * @param {String} password password + * @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key] + */ +function deriveKeys(salt, iterations, password) { + return subtleCrypto.importKey( + 'raw', + new TextEncoder().encode(password), + {name: 'PBKDF2'}, + false, + ['deriveBits'] + ).then((key) => { + return subtleCrypto.deriveBits( + { + name: 'PBKDF2', + salt: salt, + iterations: iterations, + hash: 'SHA-512', + }, + key, + 512 + ); + }).then((keybits) => { + const aes_key = keybits.slice(0, 32); + const hmac_key = keybits.slice(32); + + const aes_prom = subtleCrypto.importKey( + 'raw', + aes_key, + {name: 'AES-CTR'}, + false, + ['encrypt', 'decrypt'] + ); + const hmac_prom = subtleCrypto.importKey( + 'raw', + hmac_key, + { + name: 'HMAC', + hash: {name: 'SHA-256'}, + }, + false, + ['sign', 'verify'] + ); + return Promise.all([aes_prom, hmac_prom]); + }); +} + +const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----'; +const TRAILER_LINE = '-----END MEGOLM SESSION DATA-----'; + +/** + * Unbase64 an ascii-armoured megolm key file + * + * Strips the header and trailer lines, and unbase64s the content + * + * @param {ArrayBuffer} data input file + * @return {Uint8Array} unbase64ed content + */ +function unpackMegolmKeyFile(data) { + // parse the file as a great big String. This should be safe, because there + // should be no non-ASCII characters, and it means that we can do string + // comparisons to find the header and footer, and feed it into window.atob. + const fileStr = new TextDecoder().decode(new Uint8Array(data)); + + // look for the start line + let lineStart = 0; + while (1) { + const lineEnd = fileStr.indexOf('\n', lineStart); + if (lineEnd < 0) { + throw new Error('Header line not found'); + } + const line = fileStr.slice(lineStart, lineEnd).trim(); + + // start the next line after the newline + lineStart = lineEnd+1; + + if (line === HEADER_LINE) { + break; + } + } + + const dataStart = lineStart; + + // look for the end line + while (1) { + const lineEnd = fileStr.indexOf('\n', lineStart); + const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd) + .trim(); + if (line === TRAILER_LINE) { + break; + } + + if (lineEnd < 0) { + throw new Error('Trailer line not found'); + } + + // start the next line after the newline + lineStart = lineEnd+1; + } + + const dataEnd = lineStart; + return decodeBase64(fileStr.slice(dataStart, dataEnd)); +} + +/** + * ascii-armour a megolm key file + * + * base64s the content, and adds header and trailer lines + * + * @param {Uint8Array} data raw data + * @return {ArrayBuffer} formatted file + */ +function packMegolmKeyFile(data) { + // we split into lines before base64ing, because encodeBase64 doesn't deal + // terribly well with large arrays. + const LINE_LENGTH = (72 * 4 / 3); + const nLines = Math.ceil(data.length / LINE_LENGTH); + const lines = new Array(nLines + 3); + lines[0] = HEADER_LINE; + let o = 0; + let i; + for (i = 1; i <= nLines; i++) { + lines[i] = encodeBase64(data.subarray(o, o+LINE_LENGTH)); + o += LINE_LENGTH; + } + lines[i++] = TRAILER_LINE; + lines[i] = ''; + return (new TextEncoder().encode(lines.join('\n'))).buffer; +} + +/** + * Encode a typed array of uint8 as base64. + * @param {Uint8Array} uint8Array The data to encode. + * @return {string} The base64. + */ +function encodeBase64(uint8Array) { + // Misinterpt the Uint8Array as Latin-1. + // window.btoa expects a unicode string with codepoints in the range 0-255. + var latin1String = String.fromCharCode.apply(null, uint8Array); + // Use the builtin base64 encoder. + return window.btoa(latin1String); +} + +/** + * Decode a base64 string to a typed array of uint8. + * @param {string} base64 The base64 to decode. + * @return {Uint8Array} The decoded data. + */ +function decodeBase64(base64) { + // window.atob returns a unicode string with codepoints in the range 0-255. + var latin1String = window.atob(base64); + // Encode the string as a Uint8Array + var uint8Array = new Uint8Array(latin1String.length); + for (var i = 0; i < latin1String.length; i++) { + uint8Array[i] = latin1String.charCodeAt(i); + } + return uint8Array; +} diff --git a/src/wrappers/WithMatrixClient.js b/src/wrappers/WithMatrixClient.js index c9c9e7adb7..8e56d17dff 100644 --- a/src/wrappers/WithMatrixClient.js +++ b/src/wrappers/WithMatrixClient.js @@ -36,4 +36,4 @@ export default function(WrappedComponent) { return ; }, }); -}; +} 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/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js index 8d33e0ead3..ca2bbba2eb 100644 --- a/test/components/views/rooms/MessageComposerInput-test.js +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -158,4 +158,85 @@ describe('MessageComposerInput', () => { expect(['__', '**']).toContain(spy.args[0][1]); }); + it('should not entity-encode " in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('"'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('"'); + }); + + it('should escape characters without other markup in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('\\*escaped\\*'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('*escaped*'); + }); + + it('should escape characters with other markup in Markdown mode', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('\\*escaped\\* *italic*'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('\\*escaped\\* *italic*'); + expect(spy.args[0][2]).toEqual('*escaped* italic'); + }); + + it('should not convert -_- into a horizontal rule in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('-_-'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('-_-'); + }); + + it('should not strip tags in Markdown mode', () => { + const spy = sinon.spy(client, 'sendHtmlMessage'); + mci.enableRichtext(false); + addTextToDraft('striked-out'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('striked-out'); + expect(spy.args[0][2]).toEqual('striked-out'); + }); + + it('should not strike-through ~~~ in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('~~~striked-out~~~'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('~~~striked-out~~~'); + }); + + it('should not mark single unmarkedup paragraphs as HTML in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.'); + }); + + it('should not mark two unmarkedup paragraphs as HTML in Markdown mode', () => { + const spy = sinon.spy(client, 'sendTextMessage'); + mci.enableRichtext(false); + addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.'); + mci.handleReturn(sinon.stub()); + + expect(spy.calledOnce).toEqual(true); + expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.'); + }); }); diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js new file mode 100644 index 0000000000..28752ae529 --- /dev/null +++ b/test/utils/MegolmExportEncryption-test.js @@ -0,0 +1,116 @@ +/* +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. +*/ + +"use strict"; + +import * as MegolmExportEncryption from 'utils/MegolmExportEncryption'; + +import * as testUtils from '../test-utils'; +import expect from 'expect'; + +const TEST_VECTORS=[ + [ + "plain", + "password", + "-----BEGIN MEGOLM SESSION DATA-----\nAXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx\ncissyYBxjsfsAndErh065A8=\n-----END MEGOLM SESSION DATA-----" + ], + [ + "Hello, World", + "betterpassword", + "-----BEGIN MEGOLM SESSION DATA-----\nAW1vcmVzYWx0bW9yZXNhbHT//////////wAAAAAAAAAAAAAD6KyBpe1Niv5M5NPm4ZATsJo5nghk\nKYu63a0YQ5DRhUWEKk7CcMkrKnAUiZny\n-----END MEGOLM SESSION DATA-----" + ], + [ + "alphanumericallyalphanumericallyalphanumericallyalphanumerically", + "SWORDFISH", + "-----BEGIN MEGOLM SESSION DATA-----\nAXllc3NhbHR5Z29vZG5lc3P//////////wAAAAAAAAAAAAAD6OIW+Je7gwvjd4kYrb+49gKCfExw\nMgJBMD4mrhLkmgAngwR1pHjbWXaoGybtiAYr0moQ93GrBQsCzPbvl82rZhaXO3iH5uHo/RCEpOqp\nPgg29363BGR+/Ripq/VCLKGNbw==\n-----END MEGOLM SESSION DATA-----" + ], + [ + "alphanumericallyalphanumericallyalphanumericallyalphanumerically", + "passwordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpasswordpassword", + "-----BEGIN MEGOLM SESSION DATA-----\nAf//////////////////////////////////////////AAAD6IAZJy7IQ7Y0idqSw/bmpngEEVVh\ngsH+8ptgqxw6ZVWQnohr8JsuwH9SwGtiebZuBu5smPCO+RFVWH2cQYslZijXv/BEH/txvhUrrtCd\nbWnSXS9oymiqwUIGs08sXI33ZA==\n-----END MEGOLM SESSION DATA-----" + ] +] +; + +function stringToArray(s) { + return new TextEncoder().encode(s).buffer; +} + +describe('MegolmExportEncryption', function() { + before(function() { + // if we don't have subtlecrypto, go home now + if (!window.crypto.subtle && !window.crypto.webkitSubtle) { + this.skip(); + } + }) + + beforeEach(function() { + testUtils.beforeEach(this); + }); + + describe('decrypt', function() { + it('should handle missing header', function() { + const input=stringToArray(`-----`); + expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')}) + .toThrow('Header line not found'); + }); + + it('should handle missing trailer', function() { + const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA----- +-----`); + expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')}) + .toThrow('Trailer line not found'); + }); + + it('should decrypt a range of inputs', function(done) { + function next(i) { + if (i >= TEST_VECTORS.length) { + done(); + return; + } + + const [plain, password, input] = TEST_VECTORS[i]; + return MegolmExportEncryption.decryptMegolmKeyFile( + stringToArray(input), password + ).then((decrypted) => { + expect(decrypted).toEqual(plain); + return next(i+1); + }) + }; + return next(0).catch(done); + }); + }); + + describe('encrypt', function() { + it('should round-trip', function(done) { + const input = + 'words words many words in plain text here'.repeat(100); + + const password = 'my super secret passphrase'; + + return MegolmExportEncryption.encryptMegolmKeyFile( + input, password, {kdf_rounds: 1000}, + ).then((ciphertext) => { + return MegolmExportEncryption.decryptMegolmKeyFile( + ciphertext, password + ); + }).then((plaintext) => { + expect(plaintext).toEqual(input); + done(); + }).catch(done); + }); + }); +}); diff --git a/test/utils/generate-megolm-test-vectors.py b/test/utils/generate-megolm-test-vectors.py new file mode 100755 index 0000000000..0ce5f5e4b3 --- /dev/null +++ b/test/utils/generate-megolm-test-vectors.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python + +from __future__ import print_function + +import base64 +import json +import struct + +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import ciphers, hashes, hmac +from cryptography.hazmat.primitives.kdf import pbkdf2 +from cryptography.hazmat.primitives.ciphers import algorithms, modes + +backend = backends.default_backend() + +def parse_u128(s): + a, b = struct.unpack(">QQ", s) + return (a << 64) | b + +def encrypt_ctr(key, iv, plaintext, counter_bits=64): + alg = algorithms.AES(key) + + # Some AES-CTR implementations treat some parts of the IV as a nonce (which + # remains constant throughought encryption), and some as a counter (which + # increments every block, ie 16 bytes, and wraps after a while). Different + # implmententations use different amounts of the IV for each part. + # + # The python cryptography library uses the whole IV as a counter; to make + # it match other implementations with a given counter size, we manually + # implement wrapping the counter. + + # number of AES blocks between each counter wrap + limit = 1 << counter_bits + + # parse IV as a 128-bit int + parsed_iv = parse_u128(iv) + + # split IV into counter and nonce + counter = parsed_iv & (limit - 1) + nonce = parsed_iv & ~(limit - 1) + + # encrypt up to the first counter wraparound + size = 16 * (limit - counter) + encryptor = ciphers.Cipher( + alg, + modes.CTR(iv), + backend=backend + ).encryptor() + input = plaintext[:size] + result = encryptor.update(input) + encryptor.finalize() + offset = size + + # do remaining data starting with a counter of zero + iv = struct.pack(">QQ", nonce >> 64, nonce & ((1 << 64) - 1)) + size = 16 * limit + + while offset < len(plaintext): + encryptor = ciphers.Cipher( + alg, + modes.CTR(iv), + backend=backend + ).encryptor() + input = plaintext[offset:offset+size] + result += encryptor.update(input) + encryptor.finalize() + offset += size + + return result + +def hmac_sha256(key, message): + h = hmac.HMAC(key, hashes.SHA256(), backend=backend) + h.update(message) + return h.finalize() + +def encrypt(key, iv, salt, plaintext, iterations=1000): + """ + Returns: + (bytes) ciphertext + """ + if len(salt) != 16: + raise Exception("Expected 128 bits of salt - got %i bits" % len((salt) * 8)) + if len(iv) != 16: + raise Exception("Expected 128 bits of IV - got %i bits" % (len(iv) * 8)) + + sha = hashes.SHA512() + kdf = pbkdf2.PBKDF2HMAC(sha, 64, salt, iterations, backend) + k = kdf.derive(key) + + aes_key = k[0:32] + sha_key = k[32:] + + packed_file = ( + b"\x01" # version + + salt + + iv + + struct.pack(">L", iterations) + + encrypt_ctr(aes_key, iv, plaintext) + ) + packed_file += hmac_sha256(sha_key, packed_file) + + return ( + b"-----BEGIN MEGOLM SESSION DATA-----\n" + + base64.encodestring(packed_file) + + b"-----END MEGOLM SESSION DATA-----" + ) + +def gen(password, iv, salt, plaintext, iterations=1000): + ciphertext = encrypt( + password.encode('utf-8'), iv, salt, plaintext.encode('utf-8'), iterations + ) + return (plaintext, password, ciphertext.decode('utf-8')) + +print (json.dumps([ + gen("password", b"\x88"*16, b"saltsaltsaltsalt", "plain", 10), + gen("betterpassword", b"\xFF"*8 + b"\x00"*8, b"moresaltmoresalt", "Hello, World"), + gen("SWORDFISH", b"\xFF"*8 + b"\x00"*8, b"yessaltygoodness", "alphanumerically" * 4), + gen("password"*32, b"\xFF"*16, b"\xFF"*16, "alphanumerically" * 4), +], indent=4))