diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5008ddfcf5..0000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..c4c7fe5067 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +src/component-index.js diff --git a/.eslintrc 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..34d3af270c --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,77 @@ +const path = require('path'); + +// get the path of the js-sdk so we can extend the config +// eslint supports loading extended configs by module, +// but only if they come from a module that starts with eslint-config- +// So we load the filename directly (and it could be in node_modules/ +// or or ../node_modules/ etc) +const matrixJsSdkPath = path.dirname(require.resolve('matrix-js-sdk')); + +module.exports = { + parser: "babel-eslint", + extends: [matrixJsSdkPath + "/.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", { + // apparently people believe the length limit shouldn't apply + // to JSX. + ignorePattern: '^\\s*<', + ignoreComments: true, + code: 90, + }], + "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 9d6a114391..9a8f804644 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,3 +1,9 @@ language: node_js node_js: - node # Latest stable version of nodejs. +install: + - npm install + - (cd node_modules/matrix-js-sdk && npm install) +script: + - npm run test + - ./.travis-test-riot.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 9673c3e820..488a9814e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,168 @@ +Changes in [0.8.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6) (2017-02-04) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.3...v0.8.6) + + * Update to matrix-js-sdk 0.7.5 (no changes from 0.7.5-rc.3) + +Changes in [0.8.6-rc.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.3) (2017-02-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.2...v0.8.6-rc.3) + + * Update to matrix-js-sdk 0.7.5-rc.3 + * Fix deviceverifybuttons + [5fd7410](https://github.com/matrix-org/matrix-react-sdk/commit/827b5a6811ac6b9d1f9a3002a94f9f6ac3f1d49c) + + +Changes in [0.8.6-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.2) (2017-02-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.6-rc.1...v0.8.6-rc.2) + + * Update to new matrix-js-sdk to get support for new device change notifications interface + + +Changes in [0.8.6-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.6-rc.1) (2017-02-03) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.5...v0.8.6-rc.1) + + * Fix timeline & notifs panel spuriously being empty + [\#675](https://github.com/matrix-org/matrix-react-sdk/pull/675) + * UI for blacklisting unverified devices per-room & globally + [\#636](https://github.com/matrix-org/matrix-react-sdk/pull/636) + * Show better error message in statusbar after UnkDevDialog + [\#674](https://github.com/matrix-org/matrix-react-sdk/pull/674) + * Make default avatars clickable + [\#673](https://github.com/matrix-org/matrix-react-sdk/pull/673) + * Fix one read receipt randomly not appearing + [\#672](https://github.com/matrix-org/matrix-react-sdk/pull/672) + * very barebones support for warning users when rooms contain unknown devices + [\#635](https://github.com/matrix-org/matrix-react-sdk/pull/635) + * Fix expanding/unexapnding read receipts + [\#671](https://github.com/matrix-org/matrix-react-sdk/pull/671) + * show placeholder when timeline empty + [\#670](https://github.com/matrix-org/matrix-react-sdk/pull/670) + * Make read receipt's titles more explanatory + [\#669](https://github.com/matrix-org/matrix-react-sdk/pull/669) + * Fix spurious HTML tags being passed through literally + [\#667](https://github.com/matrix-org/matrix-react-sdk/pull/667) + * Reinstate max-len lint configs + [\#665](https://github.com/matrix-org/matrix-react-sdk/pull/665) + * Throw errors on !==200 status codes from RTS + [\#662](https://github.com/matrix-org/matrix-react-sdk/pull/662) + * Exempt lines which look like pure JSX from the maxlen line + [\#664](https://github.com/matrix-org/matrix-react-sdk/pull/664) + * Make tests pass on Chrome again + [\#663](https://github.com/matrix-org/matrix-react-sdk/pull/663) + * Add referral section to user settings + [\#661](https://github.com/matrix-org/matrix-react-sdk/pull/661) + * Two megolm export fixes: + [\#660](https://github.com/matrix-org/matrix-react-sdk/pull/660) + * GET /teams from RTS instead of config.json + [\#658](https://github.com/matrix-org/matrix-react-sdk/pull/658) + * Guard onStatusBarVisible/Hidden with this.unmounted + [\#656](https://github.com/matrix-org/matrix-react-sdk/pull/656) + * Fix cancel button on e2e import/export dialogs + [\#654](https://github.com/matrix-org/matrix-react-sdk/pull/654) + * Look up email addresses in ChatInviteDialog + [\#653](https://github.com/matrix-org/matrix-react-sdk/pull/653) + * Move BugReportDialog to riot-web + [\#652](https://github.com/matrix-org/matrix-react-sdk/pull/652) + * Fix dark theme styling of roomheader cancel button + [\#651](https://github.com/matrix-org/matrix-react-sdk/pull/651) + * Allow modals to stack up + [\#649](https://github.com/matrix-org/matrix-react-sdk/pull/649) + * Add bug report UI + [\#642](https://github.com/matrix-org/matrix-react-sdk/pull/642) + * Better feedback in invite dialog + [\#625](https://github.com/matrix-org/matrix-react-sdk/pull/625) + * Import and export for Megolm session data + [\#647](https://github.com/matrix-org/matrix-react-sdk/pull/647) + * Overhaul MELS to deal with causality, kicks, etc. + [\#613](https://github.com/matrix-org/matrix-react-sdk/pull/613) + * Re-add dispatcher as alt-up/down uses it + [\#650](https://github.com/matrix-org/matrix-react-sdk/pull/650) + * Create a common BaseDialog + [\#645](https://github.com/matrix-org/matrix-react-sdk/pull/645) + * Fix SetDisplayNameDialog + [\#648](https://github.com/matrix-org/matrix-react-sdk/pull/648) + * Sync typing indication with avatar typing indication + [\#643](https://github.com/matrix-org/matrix-react-sdk/pull/643) + * Warn users of E2E key loss when changing/resetting passwords or logging out + [\#646](https://github.com/matrix-org/matrix-react-sdk/pull/646) + * Better user interface for screen readers and keyboard navigation + [\#616](https://github.com/matrix-org/matrix-react-sdk/pull/616) + * Reduce log spam: Revert a16aeeef2a0f16efedf7e6616cdf3c2c8752a077 + [\#644](https://github.com/matrix-org/matrix-react-sdk/pull/644) + * Expand timeline in situations when _getIndicator not null + [\#641](https://github.com/matrix-org/matrix-react-sdk/pull/641) + * Correctly get the path of the js-sdk .eslintrc.js + [\#640](https://github.com/matrix-org/matrix-react-sdk/pull/640) + * Add 'searching known users' to the user picker + [\#621](https://github.com/matrix-org/matrix-react-sdk/pull/621) + * Add mocha env for tests in eslint config + [\#639](https://github.com/matrix-org/matrix-react-sdk/pull/639) + * Fix typing avatars displaying "me" + [\#637](https://github.com/matrix-org/matrix-react-sdk/pull/637) + * Fix device verification from e2e info + [\#638](https://github.com/matrix-org/matrix-react-sdk/pull/638) + * Make user search do a bit better on word boundary + [\#623](https://github.com/matrix-org/matrix-react-sdk/pull/623) + * Use an eslint config based on the js-sdk + [\#634](https://github.com/matrix-org/matrix-react-sdk/pull/634) + * Fix error display in account deactivate dialog + [\#633](https://github.com/matrix-org/matrix-react-sdk/pull/633) + * Configure travis to test riot-web after building + [\#629](https://github.com/matrix-org/matrix-react-sdk/pull/629) + * Sanitize ChatInviteDialog + [\#626](https://github.com/matrix-org/matrix-react-sdk/pull/626) + * (hopefully) fix theming on Chrome + [\#630](https://github.com/matrix-org/matrix-react-sdk/pull/630) + * Megolm session import and export + [\#617](https://github.com/matrix-org/matrix-react-sdk/pull/617) + * Allow Modal to be used with async-loaded components + [\#618](https://github.com/matrix-org/matrix-react-sdk/pull/618) + * Fix escaping markdown by rendering plaintext + [\#622](https://github.com/matrix-org/matrix-react-sdk/pull/622) + * Implement auto-join rooms on registration + [\#628](https://github.com/matrix-org/matrix-react-sdk/pull/628) + * Matthew/fix theme npe + [\#627](https://github.com/matrix-org/matrix-react-sdk/pull/627) + * Implement theming via alternate stylesheets + [\#624](https://github.com/matrix-org/matrix-react-sdk/pull/624) + * Replace marked with commonmark + [\#575](https://github.com/matrix-org/matrix-react-sdk/pull/575) + * Fix vector-im/riot-web#2833 : Fail nicely when people try to register + numeric user IDs + [\#619](https://github.com/matrix-org/matrix-react-sdk/pull/619) + * Show the error dialog when requests to PUT power levels fail + [\#614](https://github.com/matrix-org/matrix-react-sdk/pull/614) + +Changes in [0.8.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.5) (2017-01-16) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.5-rc.1...v0.8.5) + + * Pull in newer matrix-js-sdk for video calling fix + +Changes in [0.8.5-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.5-rc.1) (2017-01-13) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.4...v0.8.5-rc.1) + + * Build the js-sdk in the CI script + [\#612](https://github.com/matrix-org/matrix-react-sdk/pull/612) + * Fix redacted member events being visible + [\#609](https://github.com/matrix-org/matrix-react-sdk/pull/609) + * Use `getStateKey` instead of `getSender` + [\#611](https://github.com/matrix-org/matrix-react-sdk/pull/611) + * Move screen sharing error check into platform + [\#608](https://github.com/matrix-org/matrix-react-sdk/pull/608) + * Fix 'create account' link in 'forgot password' + [\#606](https://github.com/matrix-org/matrix-react-sdk/pull/606) + * Let electron users complete captchas in a web browser + [\#601](https://github.com/matrix-org/matrix-react-sdk/pull/601) + * Add support for deleting threepids + [\#597](https://github.com/matrix-org/matrix-react-sdk/pull/597) + * Display msisdn threepids as 'Phone' + [\#598](https://github.com/matrix-org/matrix-react-sdk/pull/598) + Changes in [0.8.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.4) (2016-12-24) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.3...v0.8.4) diff --git a/jenkins.sh b/jenkins.sh index b318b586e2..c1fba19e94 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -12,11 +12,14 @@ set -x # install the other dependencies npm install +# we may be using a dev branch of js-sdk in which case we need to build it +(cd node_modules/matrix-js-sdk && npm install) + # run the mocha tests 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/karma.conf.js b/karma.conf.js index 131a03ce79..6d3047bb3b 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -165,6 +165,14 @@ module.exports = function (config) { }, devtool: 'inline-source-map', }, + + webpackMiddleware: { + stats: { + // don't fill the console up with a mahoosive list of modules + chunks: false, + }, + }, + browserNoActivityTimeout: 15000, }); }; diff --git a/package.json b/package.json index 1eaee39c41..6e7013fb93 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.8.4", + "version": "0.8.6", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -10,6 +10,7 @@ "license": "Apache-2.0", "main": "lib/index.js", "files": [ + ".eslintrc.js", "CHANGELOG.md", "CONTRIBUTING.rst", "LICENSE", @@ -46,10 +47,12 @@ "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", + "commonmark": "^0.27.0", "draft-js": "^0.8.1", "draft-js-export-html": "^0.5.0", "draft-js-export-markdown": "^0.2.0", "emojione": "2.2.3", + "file-saver": "^1.3.3", "filesize": "^3.1.2", "flux": "^2.0.3", "fuse.js": "^2.2.0", @@ -58,7 +61,6 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "marked": "^0.3.5", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", @@ -67,13 +69,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 +88,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 a8fb763a8d..b27ed9e159 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -96,16 +96,16 @@ var sanitizeHtmlParams = { ], allowedAttributes: { // custom ones first: - font: [ 'color' , 'style' ], // custom to matrix - a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix + font: ['color', 'style'], // 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 @@ -320,7 +320,7 @@ export function bodyToHtml(content, highlights, opts) { } EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body.trim(); + let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; let match = EMOJI_REGEX.exec(contentBodyTrimmed); let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; 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 6422812734..d1f03fe211 100644 --- a/src/Invite.js +++ b/src/Invite.js @@ -19,9 +19,12 @@ import MultiInviter from './utils/MultiInviter'; const emailRegex = /^\S+@\S+\.\S+$/; +// We allow localhost for mxids to avoid confusion +const mxidRegex = /^@\S+:(?:\S+\.\S+|localhost)$/ + export function getAddressType(inputText) { - const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText); - const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0; + const isEmailAddress = emailRegex.test(inputText); + const isMatrixId = mxidRegex.test(inputText); // sanity check the input for user IDs if (isEmailAddress) { @@ -55,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/KeyCode.js b/src/KeyCode.js index bbe1ddcefa..c9cac01239 100644 --- a/src/KeyCode.js +++ b/src/KeyCode.js @@ -20,6 +20,7 @@ module.exports = { TAB: 9, ENTER: 13, SHIFT: 16, + ESCAPE: 27, PAGE_UP: 33, PAGE_DOWN: 34, END: 35, diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 0a61dc6105..e899ec6ad8 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -18,11 +18,12 @@ 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'; import DMRoomMap from './utils/DMRoomMap'; +import RtsClient from './RtsClient'; /** * Called at startup, to attempt to build a logged-in Matrix session. It tries @@ -140,7 +141,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); @@ -229,6 +230,11 @@ function _restoreFromLocalStorage() { } } +let rtsClient = null; +export function initRtsClient(url) { + rtsClient = new RtsClient(url); +} + /** * Transitions to a logged-in state using the given credentials * @param {MatrixClientCreds} credentials The credentials to use @@ -261,6 +267,19 @@ export function setLoggedIn(credentials) { } catch (e) { console.warn("Error using local storage: can't persist session!", e); } + + if (rtsClient) { + rtsClient.login(credentials.userId).then((body) => { + if (body.team_token) { + localStorage.setItem("mx_team_token", body.team_token); + } + }, (err) =>{ + console.error( + "Failed to get team token on login, not persisting to localStorage", + err + ); + }); + } } else { console.warn("No local storage available: can't persist session!"); } diff --git a/src/Markdown.js b/src/Markdown.js index a7b267b110..d6dc979a5a 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -14,115 +14,144 @@ See the License for the specific language governing permissions and limitations under the License. */ -import marked from 'marked'; +import commonmark from 'commonmark'; +import escape from 'lodash/escape'; -// marked only applies the default options on the high -// level marked() interface, so we do it here. -const marked_options = Object.assign({}, marked.defaults, { - gfm: true, - tables: true, - breaks: true, - pedantic: false, - sanitize: true, - smartLists: true, - smartypants: false, - xhtml: true, // return self closing tags (ie.
not
) -}); +const ALLOWED_HTML_TAGS = ['del']; + +// These types of node are definitely text +const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; + +function is_allowed_html_tag(node) { + // Regex won't work for tags with attrs, but we only + // allow anyway. + const matches = /^<\/?(.*)>$/.exec(node.literal); + if (matches && matches.length == 2) { + const tag = matches[1]; + return ALLOWED_HTML_TAGS.indexOf(tag) > -1; + } + return false; +} + +function html_if_tag_allowed(node) { + if (is_allowed_html_tag(node)) { + this.lit(node.literal); + return; + } else { + this.lit(escape(node.literal)); + } +} + +/* + * Returns true if the parse output containing the node + * comprises multiple block level elements (ie. lines), + * or false if it is only a single line. + */ +function is_multi_line(node) { + var par = node; + while (par.parent) { + par = par.parent; + } + return par.firstChild != par.lastChild; +} /** - * Class that wraps marked, adding the ability to see whether + * Class that wraps commonmark, adding the ability to see whether * a given message actually uses any markdown syntax or whether * it's plain text. */ export default class Markdown { constructor(input) { - const lexer = new marked.Lexer(marked_options); - this.tokens = lexer.lex(input); - } + this.input = input; - _copyTokens() { - // copy tokens (the parser modifies its input arg) - const tokens_copy = this.tokens.slice(); - // it also has a 'links' property, because this is javascript - // and why wouldn't you have an array that also has properties? - return Object.assign(tokens_copy, this.tokens); + const parser = new commonmark.Parser(); + this.parsed = parser.parse(this.input); } isPlainText() { - // we determine if the message requires markdown by - // running the parser on the tokens with a dummy - // rendered and seeing if any of the renderer's - // functions are called other than those noted below. - // In case you were wondering, no we can't just examine - // the tokens because the tokens we have are only the - // output of the *first* tokenizer: any line-based - // markdown is processed by marked within Parser by - // the 'inline lexer'... - let is_plain = true; + const walker = this.parsed.walker(); - function setNotPlain() { - is_plain = false; - } - - const dummy_renderer = {}; - for (const k of Object.keys(marked.Renderer.prototype)) { - dummy_renderer[k] = setNotPlain; - } - // text and paragraph are just text - dummy_renderer.text = function(t){return t;} - dummy_renderer.paragraph = function(t){return t;} - - // ignore links where text is just the url: - // this ignores plain URLs that markdown has - // detected whilst preserving markdown syntax links - dummy_renderer.link = function(href, title, text) { - if (text != href) { - is_plain = false; + let ev; + while ( (ev = walker.next()) ) { + const node = ev.node; + if (TEXT_NODES.indexOf(node.type) > -1) { + // definitely text + continue; + } else if (node.type == 'html_inline' || node.type == 'html_block') { + // if it's an allowed html tag, we need to render it and therefore + // we will need to use HTML. If it's not allowed, it's not HTML since + // we'll just be treating it as text. + if (is_allowed_html_tag(node)) { + return false; + } + } else { + return false; } } - - const dummy_options = Object.assign({}, marked_options, { - renderer: dummy_renderer, - }); - const dummy_parser = new marked.Parser(dummy_options); - dummy_parser.parse(this._copyTokens()); - - return is_plain; + return true; } toHTML() { - const real_renderer = new marked.Renderer(); - real_renderer.link = function(href, title, text) { - // prevent marked from turning plain URLs - // into links, because its algorithm is fairly - // poor. Let's send plain URLs rather than - // badly linkified ones (the linkifier Vector - // uses on message display is way better, eg. - // handles URLs with closing parens at the end). - if (text == href) { - return href; - } - return marked.Renderer.prototype.link.apply(this, arguments); - } + const renderer = new commonmark.HtmlRenderer({safe: false}); + const real_paragraph = renderer.paragraph; - real_renderer.paragraph = (text) => { - // The tokens at the top level are the 'blocks', so if we - // have more than one, there are multiple 'paragraphs'. - // If there is only one top level token, just return the + 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 necessarily wrapped in its own - // p tag. If, however, we have multiple tokens, each gets + // '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. - if (this.tokens.length == 1) { - return text; + if (is_multi_line(node)) { + real_paragraph.call(this, node, entering); } - return '

' + text + '

'; + }; + + renderer.html_inline = html_if_tag_allowed; + renderer.html_block = function(node) { + // as with `paragraph`, we only insert line breaks + // if there are multiple lines in the markdown. + const isMultiLine = is_multi_line(node); + + if (isMultiLine) this.cr(); + html_if_tag_allowed.call(this, node); + if (isMultiLine) this.cr(); } - const real_options = Object.assign({}, marked_options, { - renderer: real_renderer, - }); - const real_parser = new marked.Parser(real_options); - return real_parser.parse(this._copyTokens()); + return renderer.render(this.parsed); + } + + /* + * Render the markdown message to plain text. That is, essentially + * just remove any backslashes escaping what would otherwise be + * markdown syntax + * (to fix https://github.com/vector-im/riot-web/issues/2870) + */ + toPlaintext() { + const renderer = new commonmark.HtmlRenderer({safe: false}); + const real_paragraph = 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. + renderer.out = function(s) { + // The `lit` function adds a string literal to the output buffer. + this.lit(s); + }; + + renderer.paragraph = function(node, entering) { + // as with toHTML, only append lines to paragraphs if there are + // multiple paragraphs + if (is_multi_line(node)) { + if (!entering && node.next) { + this.lit('\n\n'); + } + } + }; + renderer.html_block = function(node) { + this.lit(node.literal); + if (is_multi_line(node) && node.next) this.lit('\n\n'); + } + + return renderer.render(this.parsed); } } diff --git a/src/Modal.js b/src/Modal.js index 44072b9278..7be37da92e 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,44 +19,180 @@ limitations under the License. var React = require('react'); var ReactDOM = require('react-dom'); +import sdk from './index'; -module.exports = { - DialogContainerId: "mx_Dialog_Container", +const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; - getOrCreateContainer: function() { - var container = document.getElementById(this.DialogContainerId); +/** + * 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; + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Starting load of AsyncWrapper for modal'); + this.props.loader((e) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('AsyncWrapper load completed with '+e.displayName); + 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 ; + } + }, +}); + +class ModalManager { + constructor() { + this._counter = 0; + + /** list of the modals we have stacked up, with the most recent at [0] */ + this._modals = [ + /* { + elem: React component for this dialog + onFinished: caller-supplied onFinished callback + className: CSS class for the dialog wrapper div + } */ + ]; + + this.closeAll = this.closeAll.bind(this); + } + + getOrCreateContainer() { + let container = document.getElementById(DIALOG_CONTAINER_ID); if (!container) { container = document.createElement("div"); - container.id = this.DialogContainerId; + container.id = DIALOG_CONTAINER_ID; document.body.appendChild(container); } return container; - }, + } - createDialog: function (Element, props, className) { + createDialog(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(loader, props, className) { var self = this; + const modal = {}; - // never call this via modal.close() from onFinished() otherwise it will loop + // never call this from onFinished() otherwise it will loop + // + // nb explicit function() rather than arrow function, to get `arguments` var closeDialog = function() { if (props && props.onFinished) props.onFinished.apply(null, arguments); - ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); + var i = self._modals.indexOf(modal); + if (i >= 0) { + self._modals.splice(i, 1); + } + self._reRender(); }; + // don't attempt to reuse the same AsyncWrapper for different dialogs, + // otherwise we'll get confused. + const modalCount = this._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! + modal.elem = ( + + ); + modal.onFinished = props ? props.onFinished : null; + modal.className = className; + + this._modals.unshift(modal); + + this._reRender(); + return {close: closeDialog}; + } + + closeAll() { + const modals = this._modals; + this._modals = []; + + for (let i = 0; i < modals.length; i++) { + const m = modals[i]; + if (m.onFinished) { + m.onFinished(false); + } + } + + this._reRender(); + } + + _reRender() { + if (this._modals.length == 0) { + ReactDOM.unmountComponentAtNode(this.getOrCreateContainer()); + return; + } + + var modal = this._modals[0]; var dialog = ( -
+
- + {modal.elem}
-
+
); ReactDOM.render(dialog, this.getOrCreateContainer()); + } +} - return {close: closeDialog}; - }, -}; +export default new ModalManager(); 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/PageTypes.js b/src/PageTypes.js index b2e2ecf4bc..d87b363a6f 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.js @@ -16,6 +16,7 @@ limitations under the License. /** The types of page which can be shown by the LoggedInView */ export default { + HomePage: "home_page", RoomView: "room_view", UserSettings: "user_settings", CreateRoom: "create_room", 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/Resend.js b/src/Resend.js index ecf504e780..e2f0c5a1ee 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -16,17 +16,35 @@ limitations under the License. var MatrixClientPeg = require('./MatrixClientPeg'); var dis = require('./dispatcher'); +var sdk = require('./index'); +var Modal = require('./Modal'); module.exports = { resend: function(event) { MatrixClientPeg.get().resendEvent( event, MatrixClientPeg.get().getRoom(event.getRoomId()) - ).done(function() { + ).done(function(res) { dis.dispatch({ action: 'message_sent', event: event }); - }, function() { + }, function(err) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Resend got send failure: ' + err.name + '('+err+')'); + if (err.name === "UnknownDeviceError") { + var UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); + Modal.createDialog(UnknownDeviceDialog, { + devices: err.devices, + room: MatrixClientPeg.get().getRoom(event.getRoomId()), + onFinished: (r) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('UnknownDeviceDialog closed with '+r); + }, + }, "mx_Dialog_unknownDevice"); + } + dis.dispatch({ action: 'message_send_failed', event: event 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/RtsClient.js b/src/RtsClient.js new file mode 100644 index 0000000000..5cf2e811ad --- /dev/null +++ b/src/RtsClient.js @@ -0,0 +1,97 @@ +import 'whatwg-fetch'; + +function checkStatus(response) { + if (!response.ok) { + return response.text().then((text) => { + throw new Error(text); + }); + } + return response; +} + +function parseJson(response) { + return response.json(); +} + +function encodeQueryParams(params) { + return '?' + Object.keys(params).map((k) => { + return k + '=' + encodeURIComponent(params[k]); + }).join('&'); +} + +const request = (url, opts) => { + if (opts && opts.qs) { + url += encodeQueryParams(opts.qs); + delete opts.qs; + } + if (opts && opts.body) { + if (!opts.headers) { + opts.headers = {}; + } + opts.body = JSON.stringify(opts.body); + opts.headers['Content-Type'] = 'application/json'; + } + return fetch(url, opts) + .then(checkStatus) + .then(parseJson); +}; + + +export default class RtsClient { + constructor(url) { + this._url = url; + } + + getTeamsConfig() { + return request(this._url + '/teams'); + } + + /** + * Track a referral with the Riot Team Server. This should be called once a referred + * user has been successfully registered. + * @param {string} referrer the user ID of one who referred the user to Riot. + * @param {string} userId the user ID of the user being referred. + * @param {string} userEmail the email address linked to `userId`. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + trackReferral(referrer, userId, userEmail) { + return request(this._url + '/register', + { + body: { + referrer: referrer, + user_id: userId, + user_email: userEmail, + }, + method: 'POST', + } + ); + } + + getTeam(teamToken) { + return request(this._url + '/teamConfiguration', + { + qs: { + team_token: teamToken, + }, + } + ); + } + + /** + * Signal to the RTS that a login has occurred and that a user requires their team's + * token. + * @param {string} userId the user ID of the user who is a member of a team. + * @returns {Promise} a promise that resolves to { team_token: 'sometoken' } upon + * success. + */ + login(userId) { + return request(this._url + '/login', + { + qs: { + user_id: userId, + }, + } + ); + } +} 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/SdkConfig.js b/src/SdkConfig.js index 1452aaa64b..8d8e93a889 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -19,6 +19,8 @@ var DEFAULTS = { integrations_ui_url: "https://scalar.vector.im/", // Base URL to the REST interface of the integrations server integrations_rest_url: "https://scalar.vector.im/api", + // Where to send bug reports. If not specified, bugs cannot be sent. + bug_report_endpoint_url: null, }; class SdkConfig { diff --git a/src/Signup.js b/src/Signup.js index f148ac2419..022a93524c 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -91,6 +91,10 @@ class Register extends Signup { this.params.idSid = idSid; } + setReferrer(referrer) { + this.params.referrer = referrer; + } + setGuestAccessToken(token) { this.guestAccessToken = token; } @@ -203,7 +207,17 @@ class Register extends Signup { } else if (error.errcode == 'M_INVALID_USERNAME') { throw new Error("User names may only contain alphanumeric characters, underscores or dots!"); } else if (error.httpStatus >= 400 && error.httpStatus < 500) { - throw new Error(`Registration failed! (${error.httpStatus})`); + let msg = null; + if (error.message) { + msg = error.message; + } else if (error.errcode) { + msg = error.errcode; + } + if (msg) { + throw new Error(`Registration failed! (${error.httpStatus}) - ${msg}`); + } else { + throw new Error(`Registration failed! (${error.httpStatus}) - That's all we know.`); + } } else if (error.httpStatus >= 500 && error.httpStatus < 600) { throw new Error( `Server error during registration! (${error.httpStatus})` diff --git a/src/SignupStages.js b/src/SignupStages.js index 6bdc331566..cdb9d5989b 100644 --- a/src/SignupStages.js +++ b/src/SignupStages.js @@ -136,6 +136,11 @@ class EmailIdentityStage extends Stage { "&session_id=" + encodeURIComponent(this.signupInstance.getServerData().session); + // Add the user ID of the referring user, if set + if (this.signupInstance.params.referrer) { + nextLink += "&referrer=" + encodeURIComponent(this.signupInstance.params.referrer); + } + var self = this; return this.client.requestRegisterEmailToken( this.signupInstance.email, 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/UserSettingsStore.js b/src/UserSettingsStore.js index e5dba62ee7..d7d3e7bc7a 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -149,6 +149,23 @@ module.exports = { return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings); }, + getLocalSettings: function() { + var localSettingsString = localStorage.getItem('mx_local_settings') || '{}'; + return JSON.parse(localSettingsString); + }, + + getLocalSetting: function(type, defaultValue = null) { + var settings = this.getLocalSettings(); + return settings.hasOwnProperty(type) ? settings[type] : null; + }, + + setLocalSetting: function(type, value) { + var settings = this.getLocalSettings(); + settings[type] = value; + // FIXME: handle errors + localStorage.setItem('mx_local_settings', JSON.stringify(settings)); + }, + isFeatureEnabled: function(feature: string): boolean { // Disable labs for guests. if (MatrixClientPeg.get().isGuest()) return false; diff --git a/src/Velociraptor.js b/src/Velociraptor.js index d9b6b3d5dc..18c871a12d 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -62,11 +62,11 @@ module.exports = React.createClass({ oldNode.style.visibility = c.props.style.visibility; } }); - if (oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') { - oldNode.style.visibility = c.props.style.visibility; - } //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } + if (oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') { + oldNode.style.visibility = c.props.style.visibility; + } self.children[c.key] = old; } else { // new element. If we have a startStyle, use that as the style and go through @@ -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..4502b0ccd9 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -1,3 +1,19 @@ +/* +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. +*/ + var MatrixClientPeg = require("./MatrixClientPeg"); module.exports = { @@ -32,18 +48,26 @@ module.exports = { return whoIsTyping; }, - whoIsTypingString: function(room) { - var whoIsTyping = this.usersTypingApartFromMe(room); + whoIsTypingString: function(whoIsTyping, limit) { + let othersCount = 0; + if (whoIsTyping.length > limit) { + othersCount = whoIsTyping.length - limit + 1; + } if (whoIsTyping.length == 0) { - return null; + return ''; } else if (whoIsTyping.length == 1) { return whoIsTyping[0].name + ' is typing'; + } + const names = whoIsTyping.map(function(m) { + return m.name; + }); + if (othersCount) { + const other = ' other' + (othersCount > 1 ? 's' : ''); + return names.slice(0, limit - 1).join(', ') + ' and ' + + othersCount + other + ' are typing'; } else { - var names = whoIsTyping.map(function(m) { - return m.name; - }); - var lastPerson = names.shift(); + const lastPerson = names.pop(); return names.join(', ') + ' and ' + lastPerson + ' are typing'; } } -} +}; diff --git a/src/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..56b9d56cc9 --- /dev/null +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -0,0 +1,175 @@ +/* +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 FileSaver from 'file-saver'; +import React from 'react'; + +import * as Matrix from 'matrix-js-sdk'; +import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; +import sdk from '../../../index'; + +const PHASE_EDIT = 1; +const PHASE_EXPORTING = 2; + +export default React.createClass({ + displayName: 'ExportE2eKeysDialog', + + propTypes: { + matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + phase: PHASE_EDIT, + errStr: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _onPassphraseFormSubmit: function(ev) { + ev.preventDefault(); + + const passphrase = this.refs.passphrase1.value; + if (passphrase !== this.refs.passphrase2.value) { + this.setState({errStr: 'Passphrases must match'}); + return false; + } + if (!passphrase) { + this.setState({errStr: 'Passphrase must not be empty'}); + return false; + } + + this._startExport(passphrase); + return false; + }, + + _startExport: function(passphrase) { + // extra Promise.resolve() to turn synchronous exceptions into + // asynchronous ones. + Promise.resolve().then(() => { + return this.props.matrixClient.exportRoomKeys(); + }).then((k) => { + return MegolmExportEncryption.encryptMegolmKeyFile( + JSON.stringify(k), passphrase, + ); + }).then((f) => { + const blob = new Blob([f], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'riot-keys.txt'); + this.props.onFinished(true); + }).catch((e) => { + if (this._unmounted) { + return; + } + this.setState({ + errStr: e.message, + phase: PHASE_EDIT, + }); + }); + + this.setState({ + errStr: null, + phase: PHASE_EXPORTING, + }); + }, + + _onCancelClick: function(ev) { + ev.preventDefault(); + this.props.onFinished(false); + return false; + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const disableForm = (this.state.phase === PHASE_EXPORTING); + + return ( + +
+
+

+ This process allows you to export the keys for messages + you have received in encrypted rooms to a local file. You + will then be able to import the file into another Matrix + client in the future, so that client will also be able to + decrypt these messages. +

+

+ The exported file will allow anyone who can read it to decrypt + any encrypted messages that you can see, so you should be + careful to keep it secure. To help with this, you should enter + a passphrase below, which will be used to encrypt the exported + data. It will only be possible to import the data by using the + same passphrase. +

+
+ {this.state.errStr} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+ ); + }, +}); diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js new file mode 100644 index 0000000000..ddd13813e2 --- /dev/null +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -0,0 +1,174 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import * as Matrix from 'matrix-js-sdk'; +import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; +import sdk from '../../../index'; + +function readFileAsArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + resolve(e.target.result); + }; + reader.onerror = reject; + + reader.readAsArrayBuffer(file); + }); +} + +const PHASE_EDIT = 1; +const PHASE_IMPORTING = 2; + +export default React.createClass({ + displayName: 'ImportE2eKeysDialog', + + propTypes: { + matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + getInitialState: function() { + return { + enableSubmit: false, + phase: PHASE_EDIT, + errStr: null, + }; + }, + + componentWillMount: function() { + this._unmounted = false; + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + _onFormChange: function(ev) { + const files = this.refs.file.files || []; + this.setState({ + enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0), + }); + }, + + _onFormSubmit: function(ev) { + ev.preventDefault(); + this._startImport(this.refs.file.files[0], this.refs.passphrase.value); + return false; + }, + + _startImport: function(file, passphrase) { + this.setState({ + errStr: null, + phase: PHASE_IMPORTING, + }); + + return readFileAsArrayBuffer(file).then((arrayBuffer) => { + return MegolmExportEncryption.decryptMegolmKeyFile( + arrayBuffer, passphrase, + ); + }).then((keys) => { + return this.props.matrixClient.importRoomKeys(JSON.parse(keys)); + }).then(() => { + // TODO: it would probably be nice to give some feedback about what we've imported here. + this.props.onFinished(true); + }).catch((e) => { + if (this._unmounted) { + return; + } + this.setState({ + errStr: e.message, + phase: PHASE_EDIT, + }); + }); + }, + + _onCancelClick: function(ev) { + ev.preventDefault(); + this.props.onFinished(false); + return false; + }, + + render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + const disableForm = (this.state.phase !== PHASE_EDIT); + + return ( + +
+
+

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

+

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

+
+ {this.state.errStr} +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+ ); + }, +}); 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..5b28be0627 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -71,18 +71,16 @@ import views$create_room$Presets from './components/views/create_room/Presets'; views$create_room$Presets && (module.exports.components['views.create_room.Presets'] = views$create_room$Presets); import views$create_room$RoomAlias from './components/views/create_room/RoomAlias'; views$create_room$RoomAlias && (module.exports.components['views.create_room.RoomAlias'] = views$create_room$RoomAlias); +import views$dialogs$BaseDialog from './components/views/dialogs/BaseDialog'; +views$dialogs$BaseDialog && (module.exports.components['views.dialogs.BaseDialog'] = views$dialogs$BaseDialog); import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog'; views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; 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'; views$dialogs$InteractiveAuthDialog && (module.exports.components['views.dialogs.InteractiveAuthDialog'] = views$dialogs$InteractiveAuthDialog); -import views$dialogs$LogoutPrompt from './components/views/dialogs/LogoutPrompt'; -views$dialogs$LogoutPrompt && (module.exports.components['views.dialogs.LogoutPrompt'] = views$dialogs$LogoutPrompt); import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog'; views$dialogs$NeedToRegisterDialog && (module.exports.components['views.dialogs.NeedToRegisterDialog'] = views$dialogs$NeedToRegisterDialog); import views$dialogs$QuestionDialog from './components/views/dialogs/QuestionDialog'; @@ -91,6 +89,10 @@ import views$dialogs$SetDisplayNameDialog from './components/views/dialogs/SetDi views$dialogs$SetDisplayNameDialog && (module.exports.components['views.dialogs.SetDisplayNameDialog'] = views$dialogs$SetDisplayNameDialog); import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputDialog'; views$dialogs$TextInputDialog && (module.exports.components['views.dialogs.TextInputDialog'] = views$dialogs$TextInputDialog); +import views$dialogs$UnknownDeviceDialog from './components/views/dialogs/UnknownDeviceDialog'; +views$dialogs$UnknownDeviceDialog && (module.exports.components['views.dialogs.UnknownDeviceDialog'] = views$dialogs$UnknownDeviceDialog); +import views$elements$AccessibleButton from './components/views/elements/AccessibleButton'; +views$elements$AccessibleButton && (module.exports.components['views.elements.AccessibleButton'] = views$elements$AccessibleButton); import views$elements$AddressSelector from './components/views/elements/AddressSelector'; views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector); import views$elements$AddressTile from './components/views/elements/AddressTile'; diff --git a/src/components/structures/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..fc4cbd9423 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() { @@ -105,6 +105,7 @@ var FilePanel = React.createClass({ showUrlPreview = { false } tileShape="file_grid" opacity={ this.props.opacity } + empty="There are no visible files in this room" /> ); } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 7c0fe14edd..ba63794f60 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -21,6 +21,7 @@ import KeyCode from '../../KeyCode'; import Notifier from '../../Notifier'; import PageTypes from '../../PageTypes'; import sdk from '../../index'; +import dis from '../../dispatcher'; /** * This is what our MatrixChat shows when we are logged in. The precise view is @@ -41,6 +42,8 @@ export default React.createClass({ onRoomCreated: React.PropTypes.func, onUserSettingsClose: React.PropTypes.func, + teamToken: React.PropTypes.string, + // and lots and lots of other stuff. }, @@ -136,6 +139,7 @@ export default React.createClass({ var UserSettings = sdk.getComponent('structures.UserSettings'); var CreateRoom = sdk.getComponent('structures.CreateRoom'); var RoomDirectory = sdk.getComponent('structures.RoomDirectory'); + var HomePage = sdk.getComponent('structures.HomePage'); var MatrixToolbar = sdk.getComponent('globals.MatrixToolbar'); var GuestWarningBar = sdk.getComponent('globals.GuestWarningBar'); var NewVersionBar = sdk.getComponent('globals.NewVersionBar'); @@ -160,8 +164,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 +174,39 @@ 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 = + referralBaseUrl={this.props.config.referralBaseUrl} + />; + 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 = ; + break; + + case PageTypes.HomePage: + page_element = 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..ab73ba366b 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() { @@ -190,6 +190,11 @@ module.exports = React.createClass({ if (this.props.config.sync_timeline_limit) { MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit; } + + // Use the locally-stored team token first, then as a fall-back, check to see if + // a referral link was used, which will contain a query parameter `team_token`. + this._teamToken = window.localStorage.getItem('mx_team_token') || + this.props.startingFragmentQueryParams.team_token; }, componentDidMount: function() { @@ -210,6 +215,12 @@ module.exports = React.createClass({ window.addEventListener('resize', this.handleResize); this.handleResize(); + if (this.props.config.teamServerConfig && + this.props.config.teamServerConfig.teamServerURL + ) { + Lifecycle.initRtsClient(this.props.config.teamServerConfig.teamServerURL); + } + // the extra q() ensures that synchronous exceptions hit the same codepath as // asynchronous ones. q().then(() => { @@ -421,6 +432,10 @@ module.exports = React.createClass({ this._setPage(PageTypes.RoomDirectory); this.notifyNewScreen('directory'); break; + case 'view_home_page': + this._setPage(PageTypes.HomePage); + this.notifyNewScreen('home'); + break; case 'view_create_chat': this._createChat(); break; @@ -456,6 +471,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 +604,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 */ @@ -643,7 +705,11 @@ module.exports = React.createClass({ )[0].roomId; self.setState({ready: true, currentRoomId: firstRoom, page_type: PageTypes.RoomView}); } else { - self.setState({ready: true, page_type: PageTypes.RoomDirectory}); + if (self._teamToken) { + self.setState({ready: true, page_type: PageTypes.HomePage}); + } else { + self.setState({ready: true, page_type: PageTypes.RoomDirectory}); + } } } else { self.setState({ready: true, page_type: PageTypes.RoomView}); @@ -663,7 +729,11 @@ module.exports = React.createClass({ } else { // There is no information on presentedId // so point user to fallback like /directory - self.notifyNewScreen('directory'); + if (self._teamToken) { + self.notifyNewScreen('home'); + } else { + self.notifyNewScreen('directory'); + } } dis.dispatch({action: 'focus_composer'}); @@ -687,6 +757,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) { @@ -717,6 +797,10 @@ module.exports = React.createClass({ dis.dispatch({ action: 'view_user_settings', }); + } else if (screen == 'home') { + dis.dispatch({ + action: 'view_home_page', + }); } else if (screen == 'directory') { dis.dispatch({ action: 'view_room_directory', @@ -976,10 +1060,11 @@ module.exports = React.createClass({ onRoomIdResolved={this.onRoomIdResolved} onRoomCreated={this.onRoomCreated} onUserSettingsClose={this.onUserSettingsClose} + teamToken={this._teamToken} {...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'); @@ -998,11 +1083,13 @@ module.exports = React.createClass({ sessionId={this.state.register_session_id} idSid={this.state.register_id_sid} email={this.props.startingFragmentQueryParams.email} + referrer={this.props.startingFragmentQueryParams.referrer} username={this.state.upgradeUsername} guestAccessToken={this.state.guestAccessToken} defaultHsUrl={this.getDefaultHsUrl()} defaultIsUrl={this.getDefaultIsUrl()} brand={this.props.config.brand} + teamServerConfig={this.props.config.teamServerConfig} 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 affa33a3e8..dcebe38fa4 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; @@ -281,8 +281,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]; @@ -295,8 +294,8 @@ module.exports = React.createClass({ var last = (i == lastShownEventIndex); - // Wrap consecutive member events in a ListSummary - if (isMembershipChange(mxEv)) { + // Wrap consecutive member events in a ListSummary, ignore if redacted + if (isMembershipChange(mxEv) && EventTile.haveTileForEvent(mxEv)) { let ts1 = mxEv.getTs(); // Ensure that the key of the MemberEventListSummary does not change with new // member events. This will prevent it from being re-created unnecessarily, and @@ -317,6 +316,11 @@ module.exports = React.createClass({ for (;i + 1 < this.props.events.length; i++) { let collapsedMxEv = this.props.events[i + 1]; + // Ignore redacted member events + if (!EventTile.haveTileForEvent(collapsedMxEv)) { + continue; + } + if (!isMembershipChange(collapsedMxEv) || this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) { break; @@ -335,7 +339,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/NotificationPanel.js b/src/components/structures/NotificationPanel.js index 7d9e752657..16f9723c76 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -48,6 +48,7 @@ var NotificationPanel = React.createClass({ showUrlPreview = { false } opacity={ this.props.opacity } tileShape="notif" + empty="You have no visible notifications" /> ); } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index c6f2d6500b..288ca0b974 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -19,6 +19,12 @@ var sdk = require('../../index'); var dis = require("../../dispatcher"); var WhoIsTyping = require("../../WhoIsTyping"); var MatrixClientPeg = require("../../MatrixClientPeg"); +const MemberAvatar = require("../views/avatars/MemberAvatar"); + +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', @@ -33,8 +39,8 @@ module.exports = React.createClass({ // the number of messages which have arrived since we've been scrolled up numUnreadMessages: React.PropTypes.number, - // true if there are messages in the room which had errors on send - hasUnsentMessages: React.PropTypes.bool, + // string to display when there are messages in the room which had errors on send + unsentMessageError: React.PropTypes.string, // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. @@ -45,6 +51,10 @@ module.exports = React.createClass({ // more interesting) hasActiveCall: React.PropTypes.bool, + // Number of names to display in typing indication. E.g. set to 3, will + // result in "X, Y, Z and 100 others are typing." + whoIsTypingLimit: React.PropTypes.number, + // callback for when the user clicks on the 'resend all' button in the // 'unsent messages' bar onResendAllClick: React.PropTypes.func, @@ -60,12 +70,26 @@ module.exports = React.createClass({ // status bar. This is used to trigger a re-layout in the parent // component. onResize: React.PropTypes.func, + + // callback for when the status bar can be hidden from view, as it is + // not displaying anything + onHidden: React.PropTypes.func, + + // callback for when the status bar is displaying something and should + // be visible + onVisible: React.PropTypes.func, + }, + + getDefaultProps: function() { + return { + whoIsTypingLimit: 3, + }; }, getInitialState: function() { return { syncState: MatrixClientPeg.get().getSyncState(), - whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), }; }, @@ -78,6 +102,20 @@ module.exports = React.createClass({ if(this.props.onResize && this._checkForResize(prevProps, prevState)) { this.props.onResize(); } + + const size = this._getSize(this.props, this.state); + if (size > 0) { + this.props.onVisible(); + } else { + if (this.hideDebouncer) { + clearTimeout(this.hideDebouncer); + } + this.hideDebouncer = setTimeout(() => { + // temporarily stop hiding the statusbar as per + // https://github.com/vector-im/riot-web/issues/1991#issuecomment-276953915 + // this.props.onHidden(); + }, HIDE_DEBOUNCE_MS); + } }, componentWillUnmount: function() { @@ -100,39 +138,33 @@ module.exports = React.createClass({ onRoomMemberTyping: function(ev, member) { this.setState({ - whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), }); }, + // 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(props, state) { + if (state.syncState === "ERROR" || + (state.usersTyping.length > 0) || + props.numUnreadMessages || + !props.atEndOfLiveTimeline || + props.hasActiveCall) { + return STATUS_BAR_EXPANDED; + } else if (props.tabCompleteEntries) { + return STATUS_BAR_HIDDEN; + } else if (props.unsentMessageError) { + 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 +205,8 @@ module.exports = React.createClass({ if (wantPlaceholder) { return ( -
- . - . - . +
+ {this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit)}
); } @@ -184,6 +214,39 @@ module.exports = React.createClass({ return null; }, + _renderTypingIndicatorAvatars: function(limit) { + let users = this.state.usersTyping; + + let othersCount = 0; + if (users.length > limit) { + othersCount = users.length - limit + 1; + users = users.slice(0, limit - 1); + } + + 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() { @@ -223,12 +286,12 @@ module.exports = React.createClass({ ); } - if (this.props.hasUnsentMessages) { + if (this.props.unsentMessageError) { return (
/!\
- Some of your messages have not been sent. + { this.props.unsentMessageError }
@@ -282,7 +348,7 @@ module.exports = React.createClass({ render: function() { var content = this._getContent(); - var indicator = this._getIndicator(this.state.whoisTypingString !== null); + var indicator = this._getIndicator(this.state.usersTyping.length > 0); return (
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8059cd9372..432dc5b724 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({ @@ -128,7 +128,7 @@ module.exports = React.createClass({ draggingFile: false, searching: false, searchResults: null, - hasUnsentMessages: false, + unsentMessageError: '', callState: null, guestsCanJoin: false, canPeek: false, @@ -146,7 +146,9 @@ module.exports = React.createClass({ showTopUnreadMessagesBar: false, auxPanelMaxHeight: undefined, - } + + statusBarVisible: false, + }; }, componentWillMount: function() { @@ -180,7 +182,7 @@ module.exports = React.createClass({ room: room, roomId: result.room_id, roomLoading: !room, - hasUnsentMessages: this._hasUnsentMessages(room), + unsentMessageError: this._getUnsentMessageError(room), }, this._onHaveRoom); }, (err) => { this.setState({ @@ -194,7 +196,7 @@ module.exports = React.createClass({ roomId: this.props.roomAddress, room: room, roomLoading: !room, - hasUnsentMessages: this._hasUnsentMessages(room), + unsentMessageError: this._getUnsentMessageError(room), }, this._onHaveRoom); } }, @@ -395,7 +397,7 @@ module.exports = React.createClass({ case 'message_sent': case 'message_send_cancelled': this.setState({ - hasUnsentMessages: this._hasUnsentMessages(this.state.room) + unsentMessageError: this._getUnsentMessageError(this.state.room), }); break; case 'notifier_enabled': @@ -634,8 +636,15 @@ module.exports = React.createClass({ } }, 500), - _hasUnsentMessages: function(room) { - return this._getUnsentMessages(room).length > 0; + _getUnsentMessageError: function(room) { + const unsentMessages = this._getUnsentMessages(room); + if (!unsentMessages.length) return ""; + for (const event of unsentMessages) { + if (!event.error || event.error.name !== "UnknownDeviceError") { + return "Some of your messages have not been sent."; + } + } + return "Message not sent due to unknown devices being present"; }, _getUnsentMessages: function(room) { @@ -674,8 +683,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"); @@ -719,15 +729,11 @@ module.exports = React.createClass({ if (!result.displayname) { var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog'); var dialog_defer = q.defer(); - var dialog_ref; Modal.createDialog(SetDisplayNameDialog, { currentDisplayName: result.displayname, - ref: (r) => { - dialog_ref = r; - }, - onFinished: (submitted) => { + onFinished: (submitted, newDisplayName) => { if (submitted) { - cli.setDisplayName(dialog_ref.getValue()).done(() => { + cli.setDisplayName(newDisplayName).done(() => { dialog_defer.resolve(); }); } @@ -758,7 +764,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 +968,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 +1031,7 @@ module.exports = React.createClass({ if (scrollPanel) { scrollPanel.checkScroll(); } - } + }; var lastRoomId; @@ -1090,7 +1096,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 +1105,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 +1189,7 @@ module.exports = React.createClass({ this.setState({ searching: true }); }, - onCancelSearchClick: function () { + onCancelSearchClick: function() { this.setState({ searching: false, searchResults: null, @@ -1208,8 +1214,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 +1338,20 @@ module.exports = React.createClass({ // no longer anything to do here }, + onStatusBarVisible: function() { + if (this.unmounted) return; + this.setState({ + statusBarVisible: true, + }); + }, + + onStatusBarHidden: function() { + if (this.unmounted) return; + this.setState({ + statusBarVisible: false, + }); + }, + showSettings: function(show) { // XXX: this is a bit naughty; we should be doing this via props if (show) { @@ -1495,25 +1516,29 @@ module.exports = React.createClass({ }); var statusBar; + let isStatusAreaExpanded = true; if (ContentMessages.getCurrentUploads().length > 0) { var UploadBar = sdk.getComponent('structures.UploadBar'); - statusBar = + statusBar = ; } else if (!this.state.searchResults) { var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); - + isStatusAreaExpanded = this.state.statusBarVisible; statusBar = + onVisible={this.onStatusBarVisible} + onHidden={this.onStatusBarHidden} + whoIsTypingLimit={3} + />; } var aux = null; @@ -1569,7 +1594,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 +1622,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 +1639,7 @@ module.exports = React.createClass({ { zoomButton } { statusBar } -
+
; } // if we have search results, we keep the messagepanel (so that it preserves its @@ -1667,6 +1692,10 @@ module.exports = React.createClass({ ); } + let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable"; + if (isStatusAreaExpanded) { + statusBarAreaClass += " mx_RoomView_statusArea_expanded"; + } return (
@@ -1689,7 +1718,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..4a0faae9db 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -25,7 +25,7 @@ var DEBUG_SCROLL = false; // The amount of extra scroll distance to allow prior to unfilling. // See _getExcessHeight. -const UNPAGINATION_PADDING = 1500; +const UNPAGINATION_PADDING = 3000; // The number of milliseconds to debounce calls to onUnfillRequest, to prevent // many scroll events causing many unfilling requests. const UNFILL_REQUEST_DEBOUNCE_MS = 200; @@ -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. @@ -570,7 +570,7 @@ module.exports = React.createClass({ var boundingRect = node.getBoundingClientRect(); var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; - debuglog("Scrolling to token '" + node.dataset.scrollToken + "'+" + + debuglog("ScrollPanel: scrolling to token '" + node.dataset.scrollToken + "'+" + pixelOffset + " (delta: "+scrollDelta+")"); if(scrollDelta != 0) { @@ -582,7 +582,7 @@ module.exports = React.createClass({ _saveScrollState: function() { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; - debuglog("Saved scroll state", this.scrollState); + debuglog("ScrollPanel: Saved scroll state", this.scrollState); return; } @@ -600,13 +600,13 @@ module.exports = React.createClass({ stuckAtBottom: false, trackedScrollToken: node.dataset.scrollToken, pixelOffset: wrapperRect.bottom - boundingRect.bottom, - } - debuglog("Saved scroll state", this.scrollState); + }; + debuglog("ScrollPanel: saved scroll state", this.scrollState); return; } } - debuglog("Unable to save scroll state: found no children in the viewport"); + debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); }, _restoreSavedScrollState: function() { @@ -640,7 +640,7 @@ module.exports = React.createClass({ this._lastSetScroll = scrollNode.scrollTop; } - debuglog("Set scrollTop:", scrollNode.scrollTop, + debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop, "requested:", scrollTop, "_lastSetScroll:", this._lastSetScroll); }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index bf31f44315..cb42f701a3 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() {}; } /* @@ -96,6 +96,9 @@ var TimelinePanel = React.createClass({ // shape property to be passed to EventTiles tileShape: React.PropTypes.string, + + // placeholder text to use if the timeline is empty + empty: React.PropTypes.string, }, statics: { @@ -322,7 +325,7 @@ var TimelinePanel = React.createClass({ }); }, - onMessageListScroll: function () { + onMessageListScroll: function() { if (this.props.onScroll) { this.props.onScroll(); } @@ -387,7 +390,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 +567,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 +648,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 +825,7 @@ var TimelinePanel = React.createClass({ description: message, onFinished: onFinished, }); - } + }; var prom = this._timelineWindow.load(eventId, INITIAL_SIZE); @@ -843,7 +847,7 @@ var TimelinePanel = React.createClass({ timelineLoading: true, }); - prom = prom.then(onLoaded, onError) + prom = prom.then(onLoaded, onError); } prom.done(); @@ -868,7 +872,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 +934,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); @@ -988,6 +993,14 @@ var TimelinePanel = React.createClass({ ); } + if (this.state.events.length == 0 && !this.state.canBackPaginate && this.props.empty) { + return ( +
+
{ this.props.empty }
+
+ ); + } + // give the messagepanel a stickybottom if we're at the end of the // live timeline, so that the arrival of new events triggers a // scroll. diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 794fcffec7..8266a11bc8 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(/ .*/, ''); } @@ -90,8 +90,8 @@ module.exports = React.createClass({displayName: 'UploadBar',
- - +
diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index a41eab3a76..fdade60dfd 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -26,12 +26,73 @@ var UserSettingsStore = require('../../UserSettingsStore'); var GeminiScrollbar = require('react-gemini-scrollbar'); var Email = require('../../email'); var AddThreepid = require('../../AddThreepid'); +var SdkConfig = require('../../SdkConfig'); +import AccessibleButton from '../views/elements/AccessibleButton'; // if this looks like a release, use the 'version' from package.json; else use // the git sha. 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', + }, +*/ +]; + +const CRYPTO_SETTINGS_LABELS = [ + { + id: 'blacklistUnverifiedDevices', + label: 'Never send encrypted messages to unverified devices from this device', + }, + // XXX: this is here for documentation; the actual setting is managed via RoomSettings + // { + // id: 'blacklistUnverifiedDevicesPerRoom' + // label: 'Never send encrypted messages to unverified devices in this room', + // } +]; + +// 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', @@ -43,6 +104,9 @@ module.exports = React.createClass({ // True to show the 'labs' section of experimental features enableLabs: React.PropTypes.bool, + // The base URL to use in the referral link. Defaults to window.location.origin. + referralBaseUrl: React.PropTypes.string, + // true if RightPanel is collapsed collapsedRhs: React.PropTypes.bool, }, @@ -93,6 +157,14 @@ module.exports = React.createClass({ middleOpacity: 0.3, }); this._refreshFromServer(); + + var syncedSettings = UserSettingsStore.getSyncedSettings(); + if (!syncedSettings.theme) { + syncedSettings.theme = 'light'; + } + this._syncedSettings = syncedSettings; + + this._localSettings = UserSettingsStore.getLocalSettings(); }, componentDidMount: function() { @@ -175,8 +247,26 @@ module.exports = React.createClass({ }, onLogoutClicked: function(ev) { - var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt'); - this.logoutModal = Modal.createDialog(LogoutPrompt); + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Sign out?", + description: +
+ For security, logging out will delete any end-to-end encryption keys from this browser, + making previous encrypted chat history unreadable if you log back in. + In future this will be improved, + but for now be warned. +
, + button: "Sign out", + onFinished: (confirmed) => { + if (confirmed) { + dis.dispatch({action: 'logout'}); + if (this.props.onFinished) { + this.props.onFinished(); + } + } + }, + }); }, onPasswordChangeError: function(err) { @@ -293,8 +383,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, @@ -316,6 +406,14 @@ module.exports = React.createClass({ Modal.createDialog(DeactivateAccountDialog, {}); }, + _onBugReportClicked: function() { + const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); + if (!BugReportDialog) { + return; + } + Modal.createDialog(BugReportDialog, {}); + }, + _onInviteStateChange: function(event, member, oldMembership) { if (member.userId === this._me && oldMembership === "invite") { this.forceUpdate(); @@ -339,81 +437,175 @@ module.exports = React.createClass({ }).done(); }, + _onExportE2eKeysClicked: function() { + Modal.createDialogAsync( + (cb) => { + require.ensure(['../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { + cb(require('../../async-components/views/dialogs/ExportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + } + ); + }, + + _onImportE2eKeysClicked: function() { + Modal.createDialogAsync( + (cb) => { + require.ensure(['../../async-components/views/dialogs/ImportE2eKeysDialog'], () => { + cb(require('../../async-components/views/dialogs/ImportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + } + ); + }, + + _renderReferral: function() { + const teamToken = window.localStorage.getItem('mx_team_token'); + if (!teamToken) { + return null; + } + if (typeof teamToken !== 'string') { + console.warn('Team token not a string'); + return null; + } + const href = (this.props.referralBaseUrl || window.location.origin) + + `/#/register?referrer=${this._me}&team_token=${teamToken}`; + return ( +
+ ); + }, + _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; const identityKey = client.getDeviceEd25519Key() || ""; + let importExportButtons = null; + + if (client.isCryptoEnabled) { + importExportButtons = ( +
+ + Export E2E room keys + + + Import E2E room keys + +
+ ); + } return (

Cryptography

    -
  • {deviceId}
  • -
  • {identityKey}
  • +
  • {deviceId}
  • +
  • {identityKey}
+ { importExportButtons } +
+
+ { CRYPTO_SETTINGS_LABELS.map( this._renderLocalSetting ) }
); }, + _renderLocalSetting: function(setting) { + const client = MatrixClientPeg.get(); + return
+ { + UserSettingsStore.setLocalSetting(setting.id, e.target.checked) + if (setting.id === 'blacklistUnverifiedDevices') { // XXX: this is a bit ugly + client.setGlobalBlacklistUnverifiedDevices(e.target.checked); + } + } + } + /> + +
; + }, + _renderDevicesPanel: function() { var DevicesPanel = sdk.getComponent('settings.DevicesPanel'); return ( @@ -424,7 +616,24 @@ module.exports = React.createClass({ ); }, - _renderLabs: function () { + _renderBugReport: function() { + if (!SdkConfig.get().bug_report_endpoint_url) { + return
+ } + return ( +
+

Bug Report

+
+

Found a bug?

+ +
+
+ ); + }, + + _renderLabs: function() { // default to enabled if undefined if (this.props.enableLabs === false) return null; @@ -460,7 +669,7 @@ module.exports = React.createClass({ {features}
- ) + ); }, _renderDeactivateAccount: function() { @@ -470,9 +679,9 @@ module.exports = React.createClass({ return

Deactivate Account

- +
; }, @@ -492,10 +701,10 @@ module.exports = React.createClass({ // bind() the invited rooms so any new invites that may come in as this button is clicked // don't inadvertently get rejected as well. reject = ( - + ); } @@ -544,10 +753,10 @@ module.exports = React.createClass({
- +
-
- Remove +
+ Remove
); @@ -569,7 +778,7 @@ module.exports = React.createClass({ blurToCancel={ false } onValueChanged={ this.onAddThreepidClicked } />
-
+
Add
@@ -650,7 +859,7 @@ module.exports = React.createClass({
@@ -663,13 +872,15 @@ module.exports = React.createClass({
-
+ Sign out -
+ {accountJsx}
+ {this._renderReferral()} + {notification_area} {this._renderUserInterfaceSettings()} @@ -677,6 +888,7 @@ module.exports = React.createClass({ {this._renderDevicesPanel()} {this._renderCryptoInfo()} {this._renderBulkOptions()} + {this._renderBugReport()}

Advanced

@@ -692,7 +904,7 @@ module.exports = React.createClass({
matrix-react-sdk version: {REACT_SDK_VERSION}
- vector-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}
+ riot-web version: {this.state.vectorVersion !== null ? this.state.vectorVersion : 'unknown'}
olm version: {olmVersionString}
diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 1868c2ee73..2c10052b98 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) { @@ -87,10 +87,26 @@ module.exports = React.createClass({ this.showErrorDialog("New passwords must match each other."); } else { - this.submitPasswordReset( - this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl, - this.state.email, this.state.password - ); + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Warning", + description: +
+ Resetting password will currently reset any end-to-end encryption keys on all devices, + making encrypted chat history unreadable. + In future this may be improved, + but for now be warned. +
, + button: "Continue", + onFinished: (confirmed) => { + if (confirmed) { + this.submitPasswordReset( + this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl, + this.state.email, this.state.password + ); + } + }, + }); } }, @@ -129,7 +145,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..efe7dae723 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -25,6 +25,7 @@ var ServerConfig = require("../../views/login/ServerConfig"); var MatrixClientPeg = require("../../../MatrixClientPeg"); var RegistrationForm = require("../../views/login/RegistrationForm"); var CaptchaForm = require("../../views/login/CaptchaForm"); +var RtsClient = require("../../../RtsClient"); var MIN_PASSWORD_LENGTH = 6; @@ -47,8 +48,16 @@ module.exports = React.createClass({ defaultIsUrl: React.PropTypes.string, brand: React.PropTypes.string, email: React.PropTypes.string, + referrer: React.PropTypes.string, username: React.PropTypes.string, guestAccessToken: React.PropTypes.string, + teamServerConfig: React.PropTypes.shape({ + // Email address to request new teams + supportEmail: React.PropTypes.string.isRequired, + // URL of the riot-team-server to get team configurations and track referrals + teamServerURL: React.PropTypes.string.isRequired, + }), + teamSelected: React.PropTypes.object, defaultDeviceDisplayName: React.PropTypes.string, @@ -60,6 +69,7 @@ module.exports = React.createClass({ getInitialState: function() { return { busy: false, + teamServerBusy: false, errorText: null, // We remember the values entered by the user because // the registration form will be unmounted during the @@ -75,6 +85,7 @@ module.exports = React.createClass({ }, componentWillMount: function() { + this._unmounted = false; this.dispatcherRef = dis.register(this.onAction); // attach this to the instance rather than this.state since it isn't UI this.registerLogic = new Signup.Register( @@ -87,11 +98,44 @@ module.exports = React.createClass({ this.registerLogic.setRegistrationUrl(this.props.registrationUrl); this.registerLogic.setIdSid(this.props.idSid); this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); + if (this.props.referrer) { + this.registerLogic.setReferrer(this.props.referrer); + } this.registerLogic.recheckState(); + + if ( + this.props.teamServerConfig && + this.props.teamServerConfig.teamServerURL && + !this._rtsClient + ) { + this._rtsClient = new RtsClient(this.props.teamServerConfig.teamServerURL); + + this.setState({ + teamServerBusy: true, + }); + // GET team configurations including domains, names and icons + this._rtsClient.getTeamsConfig().then((data) => { + const teamsConfig = { + teams: data, + supportEmail: this.props.teamServerConfig.supportEmail, + }; + console.log('Setting teams config to ', teamsConfig); + this.setState({ + teamsConfig: teamsConfig, + teamServerBusy: false, + }); + }, (err) => { + console.error('Error retrieving config for teams', err); + this.setState({ + teamServerBusy: false, + }); + }); + } }, componentWillUnmount: function() { dis.unregister(this.dispatcherRef); + this._unmounted = true; }, componentDidMount: function() { @@ -169,6 +213,43 @@ module.exports = React.createClass({ accessToken: response.access_token }); + if ( + self._rtsClient && + self.props.referrer && + self.state.teamSelected + ) { + // Track referral, get team_token in order to retrieve team config + self._rtsClient.trackReferral( + self.props.referrer, + response.user_id, + self.state.formVals.email + ).then((data) => { + const teamToken = data.team_token; + // Store for use /w welcome pages + window.localStorage.setItem('mx_team_token', teamToken); + + self._rtsClient.getTeam(teamToken).then((team) => { + console.log( + `User successfully registered with team ${team.name}` + ); + if (!team.rooms) { + return; + } + // Auto-join rooms + team.rooms.forEach((room) => { + if (room.auto_join && room.room_id) { + console.log(`Auto-joining ${room.room_id}`); + MatrixClientPeg.get().joinRoom(room.room_id); + } + }); + }, (err) => { + console.error('Error getting team config', err); + }); + }, (err) => { + console.error('Error tracking referral', err); + }); + } + if (self.props.brand) { MatrixClientPeg.get().getPushers().done((resp)=>{ var pushers = resp.pushers; @@ -238,7 +319,15 @@ module.exports = React.createClass({ }); }, + onTeamSelected: function(teamSelected) { + if (!this._unmounted) { + this.setState({ teamSelected }); + } + }, + _getRegisterContentJsx: function() { + const Spinner = sdk.getComponent("elements.Spinner"); + var currStep = this.registerLogic.getStep(); var registerStep; switch (currStep) { @@ -248,16 +337,23 @@ module.exports = React.createClass({ case "Register.STEP_m.login.dummy": // NB. Our 'username' prop is specifically for upgrading // a guest account + if (this.state.teamServerBusy) { + registerStep = ; + break; + } registerStep = ( + onRegisterClick={this.onFormSubmit} + onTeamSelected={this.onTeamSelected} + /> ); break; case "Register.STEP_m.login.email.identity": @@ -286,7 +382,6 @@ module.exports = React.createClass({ } var busySpinner; if (this.state.busy) { - var Spinner = sdk.getComponent("elements.Spinner"); busySpinner = ( ); @@ -297,7 +392,7 @@ module.exports = React.createClass({ returnToAppJsx = Return to app - + ; } return ( @@ -331,7 +426,7 @@ module.exports = React.createClass({ return (
- + {this._getRegisterContentJsx()}
diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 47f0a76891..65730be40b 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -19,6 +19,7 @@ limitations under the License. var React = require('react'); var AvatarLogic = require("../../../Avatar"); import sdk from '../../../index'; +import AccessibleButton from '../elements/AccessibleButton'; module.exports = React.createClass({ displayName: 'BaseAvatar', @@ -41,7 +42,7 @@ module.exports = React.createClass({ height: 40, resizeMethod: 'crop', defaultToInitialLetter: true - } + }; }, getInitialState: function() { @@ -138,30 +139,63 @@ module.exports = React.createClass({ const { name, idName, title, url, urls, width, height, resizeMethod, - defaultToInitialLetter, + defaultToInitialLetter, onClick, ...otherProps } = this.props; if (imageUrl === this.state.defaultImageUrl) { const initialLetter = this._getInitialLetter(name); + const textNode = ( + + ); + const imgNode = ( + + ); + if (onClick != null) { + return ( + + {textNode} + {imgNode} + + ); + } else { + return ( + + {textNode} + {imgNode} + + ); + } + } + if (onClick != null) { return ( - - - - + + ); + } else { + return ( + ); } - return ( - - ); } }); 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/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js new file mode 100644 index 0000000000..2b3980c536 --- /dev/null +++ b/src/components/views/dialogs/BaseDialog.js @@ -0,0 +1,72 @@ +/* +Copyright 2017 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; + +import * as KeyCode from '../../../KeyCode'; + +/** + * Basic container for modal dialogs. + * + * Includes a div for the title, and a keypress handler which cancels the + * dialog on escape. + */ +export default React.createClass({ + displayName: 'BaseDialog', + + propTypes: { + // onFinished callback to call when Escape is pressed + onFinished: React.PropTypes.func.isRequired, + + // callback to call when Enter is pressed + onEnterPressed: React.PropTypes.func, + + // CSS class to apply to dialog div + className: React.PropTypes.string, + + // Title for the dialog. + // (could probably actually be something more complicated than a string if desired) + title: React.PropTypes.string.isRequired, + + // children should be the content of the dialog + children: React.PropTypes.node, + }, + + _onKeyDown: function(e) { + if (e.keyCode === KeyCode.ESCAPE) { + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(); + } else if (e.keyCode === KeyCode.ENTER) { + if (this.props.onEnterPressed) { + e.stopPropagation(); + e.preventDefault(); + this.props.onEnterPressed(e); + } + } + }, + + render: function() { + return ( +
+
+ { this.props.title } +
+ { this.props.children } +
+ ); + }, +}); diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index aa694f6838..ca3b07aa00 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -14,19 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); -var classNames = require('classnames'); -var sdk = require("../../../index"); -var Invite = require("../../../Invite"); -var createRoom = require("../../../createRoom"); -var MatrixClientPeg = require("../../../MatrixClientPeg"); -var DMRoomMap = require('../../../utils/DMRoomMap'); -var rate_limited_func = require("../../../ratelimitedfunc"); -var dis = require("../../../dispatcher"); -var Modal = require('../../../Modal'); +import React from 'react'; +import classNames from 'classnames'; +import sdk from '../../../index'; +import { getAddressType, inviteMultipleToRoom } from '../../../Invite'; +import createRoom from '../../../createRoom'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import DMRoomMap from '../../../utils/DMRoomMap'; +import rate_limited_func from '../../../ratelimitedfunc'; +import dis from '../../../dispatcher'; +import Modal from '../../../Modal'; +import AccessibleButton from '../elements/AccessibleButton'; +import q from 'q'; 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: { @@ -48,7 +59,7 @@ module.exports = React.createClass({ title: "Start a chat", description: "Who would you like to communicate with?", value: "", - placeholder: "User ID, Name or email", + placeholder: "Email, name or matrix ID", button: "Start Chat", focus: true }; @@ -57,7 +68,14 @@ module.exports = React.createClass({ getInitialState: function() { return { error: false, + + // List of AddressTile.InviteAddressType objects represeting + // the list of addresses we're going to invite inviteList: [], + + // List of AddressTile.InviteAddressType objects represeting + // the set of autocompletion results for the current search + // query. queryList: [], }; }, @@ -71,15 +89,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 +134,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,33 +150,56 @@ 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(); } }, onQueryChanged: function(ev) { - var query = ev.target.value; - var queryList = []; + const query = ev.target.value; + let queryList = []; // Only do search if there is something to search - if (query.length > 0) { + if (query.length > 0 && query != '@') { + // filter the known users list queryList = this._userList.filter((user) => { return this._matches(query, user); + }).map((user) => { + // Return objects, structure of which is defined + // by InviteAddressType + return { + addressType: 'mx', + address: user.userId, + displayName: user.displayName, + avatarMxc: user.avatarUrl, + isKnown: true, + } }); + + // If the query isn't a user we know about, but is a + // valid address, add an entry for that + if (queryList.length == 0) { + const addrType = getAddressType(query); + if (addrType !== null) { + queryList[0] = { + addressType: addrType, + address: query, + isKnown: false, + }; + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + if (addrType == 'email') { + this._lookupThreepid(addrType, query).done(); + } + } + } } this.setState({ @@ -179,7 +217,8 @@ module.exports = React.createClass({ inviteList: inviteList, queryList: [], }); - } + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + }; }, onClick: function(index) { @@ -191,11 +230,12 @@ module.exports = React.createClass({ onSelected: function(index) { var inviteList = this.state.inviteList.slice(); - inviteList.push(this.state.queryList[index].userId); + inviteList.push(this.state.queryList[index]); this.setState({ inviteList: inviteList, queryList: [], }); + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); }, _getDirectMessageRoom: function(addr) { @@ -226,10 +266,14 @@ module.exports = React.createClass({ return; } + const addrTexts = addrs.map((addr) => { + return addr.address; + }); + if (this.props.roomId) { // Invite new user to a room var self = this; - Invite.inviteMultipleToRoom(this.props.roomId, addrs) + inviteMultipleToRoom(this.props.roomId, addrTexts) .then(function(addrs) { var room = MatrixClientPeg.get().getRoom(self.props.roomId); return self._showAnyInviteErrors(addrs, room); @@ -244,9 +288,9 @@ module.exports = React.createClass({ return null; }) .done(); - } else if (this._isDmChat(addrs)) { + } else if (this._isDmChat(addrTexts)) { // Start the DM chat - createRoom({dmUserId: addrs[0]}) + createRoom({dmUserId: addrTexts[0]}) .catch(function(err) { console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -263,7 +307,7 @@ module.exports = React.createClass({ var room; createRoom().then(function(roomId) { room = MatrixClientPeg.get().getRoom(roomId); - return Invite.inviteMultipleToRoom(roomId, addrs); + return inviteMultipleToRoom(roomId, addrTexts); }) .then(function(addrs) { return self._showAnyInviteErrors(addrs, room); @@ -281,7 +325,7 @@ module.exports = React.createClass({ } // Close - this will happen before the above, as that is async - this.props.onFinished(true, addrs); + this.props.onFinished(true, addrTexts); }, _updateUserList: new rate_limited_func(function() { @@ -315,19 +359,27 @@ module.exports = React.createClass({ return true; } - // split spaces in name and try matching constituent parts - var parts = name.split(" "); - for (var i = 0; i < parts.length; i++) { - if (parts[i].indexOf(query) === 0) { - return true; - } + // Try to find the query following a "word boundary", except that + // this does avoids using \b because it only considers letters from + // the roman alphabet to be word characters. + // Instead, we look for the query following either: + // * The start of the string + // * Whitespace, or + // * A fixed number of punctuation characters + const expr = new RegExp("(?:^|[\\s\\(\)'\",\.-_@\?;:{}\\[\\]\\#~`\\*\\&\\$])" + escapeRegExp(query)); + if (expr.test(name)) { + return true; } + return false; }, _isOnInviteList: function(uid) { for (let i = 0; i < this.state.inviteList.length; i++) { - if (this.state.inviteList[i].toLowerCase() === uid) { + if ( + this.state.inviteList[i].addressType == 'mx' && + this.state.inviteList[i].address.toLowerCase() === uid + ) { return true; } } @@ -335,7 +387,7 @@ module.exports = React.createClass({ }, _isDmChat: function(addrs) { - if (addrs.length === 1 && Invite.getAddressType(addrs[0]) === "mx" && !this.props.roomId) { + if (addrs.length === 1 && getAddressType(addrs[0]) === "mx" && !this.props.roomId) { return true; } else { return false; @@ -361,9 +413,74 @@ module.exports = React.createClass({ return addrs; }, + _addInputToList: function() { + const addressText = this.refs.textinput.value.trim(); + const addrType = getAddressType(addressText); + const addrObj = { + addressType: addrType, + address: addressText, + isKnown: false, + }; + if (addrType == null) { + this.setState({ error: true }); + return null; + } else if (addrType == 'mx') { + const user = MatrixClientPeg.get().getUser(addrObj.address); + if (user) { + addrObj.displayName = user.displayName; + addrObj.avatarMxc = user.avatarUrl; + addrObj.isKnown = true; + } + } + + const inviteList = this.state.inviteList.slice(); + inviteList.push(addrObj); + this.setState({ + inviteList: inviteList, + queryList: [], + }); + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + return inviteList; + }, + + _lookupThreepid: function(medium, address) { + let cancelled = false; + // Note that we can't safely remove this after we're done + // because we don't know that it's the same one, so we just + // leave it: it's replacing the old one each time so it's + // not like they leak. + this._cancelThreepidLookup = function() { + cancelled = true; + } + + // wait a bit to let the user finish typing + return q.delay(500).then(() => { + if (cancelled) return null; + return MatrixClientPeg.get().lookupThreePid(medium, address); + }).then((res) => { + if (res === null || !res.mxid) return null; + if (cancelled) return null; + + return MatrixClientPeg.get().getProfileInfo(res.mxid); + }).then((res) => { + if (res === null) return null; + if (cancelled) return null; + this.setState({ + queryList: [{ + // an InviteAddressType + addressType: medium, + address: address, + displayName: res.displayname, + avatarMxc: res.avatar_url, + isKnown: true, + }] + }); + }); + }, + render: function() { - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - var AddressSelector = sdk.getComponent("elements.AddressSelector"); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const AddressSelector = sdk.getComponent("elements.AddressSelector"); this.scrollElement = null; var query = []; @@ -394,13 +511,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 } + /> ); } @@ -409,9 +531,10 @@ module.exports = React.createClass({
{this.props.title}
-
+ -
+
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/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index ed48f10fd7..937595dfa8 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -25,9 +25,10 @@ limitations under the License. * }); */ -var React = require("react"); +import React from 'react'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'ErrorDialog', propTypes: { title: React.PropTypes.string, @@ -49,20 +50,11 @@ module.exports = React.createClass({ }; }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
-
- {this.props.title} -
+
{this.props.description}
@@ -71,7 +63,7 @@ module.exports = React.createClass({ {this.props.button}
-
+ ); - } + }, }); diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 301bba0486..a4abbb17d9 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -111,20 +111,9 @@ export default React.createClass({ }); }, - _onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - if (!this.state.busy) { - this._onCancel(); - } - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - if (this.state.submitButtonEnabled && !this.state.busy) { - this._onSubmit(); - } + _onEnterPressed: function(e) { + if (this.state.submitButtonEnabled && !this.state.busy) { + this._onSubmit(); } }, @@ -171,6 +160,7 @@ export default React.createClass({ render: function() { const Loader = sdk.getComponent("elements.Spinner"); + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); let error = null; if (this.state.errorText) { @@ -200,10 +190,11 @@ export default React.createClass({ ); return ( -
-
- {this.props.title} -
+

This operation requires additional authentication.

{this._renderCurrentStage()} @@ -213,7 +204,7 @@ export default React.createClass({ {submitButton} {cancelButton}
-
+ ); }, }); diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js deleted file mode 100644 index c4bd7a0474..0000000000 --- a/src/components/views/dialogs/LogoutPrompt.js +++ /dev/null @@ -1,61 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ -var React = require('react'); -var dis = require("../../../dispatcher"); - -module.exports = React.createClass({ - displayName: 'LogoutPrompt', - - propTypes: { - onFinished: React.PropTypes.func, - }, - - logOut: function() { - dis.dispatch({action: 'logout'}); - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - cancelPrompt: function() { - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.cancelPrompt(); - } - }, - - render: function() { - return ( -
-
- Sign out? -
-
- - -
-
- ); - } -}); - diff --git a/src/components/views/dialogs/NeedToRegisterDialog.js b/src/components/views/dialogs/NeedToRegisterDialog.js index 0080e0c643..f4df5913d5 100644 --- a/src/components/views/dialogs/NeedToRegisterDialog.js +++ b/src/components/views/dialogs/NeedToRegisterDialog.js @@ -23,8 +23,9 @@ limitations under the License. * }); */ -var React = require("react"); -var dis = require("../../../dispatcher"); +import React from 'react'; +import dis from '../../../dispatcher'; +import sdk from '../../../index'; module.exports = React.createClass({ displayName: 'NeedToRegisterDialog', @@ -54,11 +55,12 @@ module.exports = React.createClass({ }, render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
-
- {this.props.title} -
+
{this.props.description}
@@ -70,7 +72,7 @@ module.exports = React.createClass({ Register
-
+ ); - } + }, }); diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 1cd4d047fd..3f7f237c30 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'QuestionDialog', propTypes: { title: React.PropTypes.string, @@ -46,25 +47,13 @@ module.exports = React.createClass({ this.props.onFinished(false); }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(true); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
-
- {this.props.title} -
+
{this.props.description}
@@ -77,7 +66,7 @@ module.exports = React.createClass({ Cancel
-
+ ); - } + }, }); diff --git a/src/components/views/dialogs/SetDisplayNameDialog.js b/src/components/views/dialogs/SetDisplayNameDialog.js index c1041cc218..1047e05c26 100644 --- a/src/components/views/dialogs/SetDisplayNameDialog.js +++ b/src/components/views/dialogs/SetDisplayNameDialog.js @@ -14,11 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); -var sdk = require("../../../index.js"); -var MatrixClientPeg = require("../../../MatrixClientPeg"); +import React from 'react'; +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; -module.exports = React.createClass({ +/** + * Prompt the user to set a display name. + * + * On success, `onFinished(true, newDisplayName)` is called. + */ +export default React.createClass({ displayName: 'SetDisplayNameDialog', propTypes: { onFinished: React.PropTypes.func.isRequired, @@ -42,10 +47,6 @@ module.exports = React.createClass({ this.refs.input_value.select(); }, - getValue: function() { - return this.state.value; - }, - onValueChange: function(ev) { this.setState({ value: ev.target.value @@ -54,16 +55,17 @@ module.exports = React.createClass({ onFormSubmit: function(ev) { ev.preventDefault(); - this.props.onFinished(true); + this.props.onFinished(true, this.state.value); return false; }, render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
-
- Set a Display Name -
+
Your display name is how you'll appear to others when you speak in rooms.
What would you like it to be? @@ -79,7 +81,7 @@ module.exports = React.createClass({
-
+ ); - } + }, }); diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 6245b5786f..6e40efffd8 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; +import sdk from '../../../index'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'TextInputDialog', propTypes: { title: React.PropTypes.string, @@ -27,7 +28,7 @@ module.exports = React.createClass({ value: React.PropTypes.string, button: React.PropTypes.string, focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired + onFinished: React.PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -36,7 +37,7 @@ module.exports = React.createClass({ value: "", description: "", button: "OK", - focus: true + focus: true, }; }, @@ -55,25 +56,13 @@ module.exports = React.createClass({ this.props.onFinished(false); }, - onKeyDown: function(e) { - if (e.keyCode === 27) { // escape - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); - } - else if (e.keyCode === 13) { // enter - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(true, this.refs.textinput.value); - } - }, - render: function() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( -
-
- {this.props.title} -
+
@@ -90,7 +79,7 @@ module.exports = React.createClass({ {this.props.button}
-
+
); - } + }, }); diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js new file mode 100644 index 0000000000..3bebb8fdda --- /dev/null +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -0,0 +1,178 @@ +/* +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 MatrixClientPeg from '../../../MatrixClientPeg'; +import GeminiScrollbar from 'react-gemini-scrollbar'; + +function DeviceListEntry(props) { + const {userId, device} = props; + + const DeviceVerifyButtons = sdk.getComponent('elements.DeviceVerifyButtons'); + + return ( +
  • + + { device.deviceId } +
    + { device.getDisplayName() } +
  • + ); +} + +DeviceListEntry.propTypes = { + userId: React.PropTypes.string.isRequired, + + // deviceinfo + device: React.PropTypes.object.isRequired, +}; + + +function UserUnknownDeviceList(props) { + const {userId, userDevices} = props; + + const deviceListEntries = Object.keys(userDevices).map((deviceId) => + , + ); + + return ( +
      + {deviceListEntries} +
    + ); +} + +UserUnknownDeviceList.propTypes = { + userId: React.PropTypes.string.isRequired, + + // map from deviceid -> deviceinfo + userDevices: React.PropTypes.object.isRequired, +}; + + +function UnknownDeviceList(props) { + const {devices} = props; + + const userListEntries = Object.keys(devices).map((userId) => +
  • +

    { userId }:

    + +
  • , + ); + + return
      {userListEntries}
    ; +} + +UnknownDeviceList.propTypes = { + // map from userid -> deviceid -> deviceinfo + devices: React.PropTypes.object.isRequired, +}; + + +export default React.createClass({ + displayName: 'UnknownEventDialog', + + propTypes: { + room: React.PropTypes.object.isRequired, + + // map from userid -> deviceid -> deviceinfo + devices: React.PropTypes.object.isRequired, + onFinished: React.PropTypes.func.isRequired, + }, + + componentDidMount: function() { + // Given we've now shown the user the unknown device, it is no longer + // unknown to them. Therefore mark it as 'known'. + Object.keys(this.props.devices).forEach((userId) => { + Object.keys(this.props.devices[userId]).map((deviceId) => { + MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); + }); + }); + + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('Opening UnknownDeviceDialog'); + }, + + render: function() { + const client = MatrixClientPeg.get(); + const blacklistUnverified = client.getGlobalBlacklistUnverifiedDevices() || + this.props.room.getBlacklistUnverifiedDevices(); + + let warning; + if (blacklistUnverified) { + warning = ( +

    + You are currently blacklisting unverified devices; to send + messages to these devices you must verify them. +

    + ); + } else { + warning = ( +
    +

    + This means there is no guarantee that the devices + belong to the users they claim to. +

    +

    + We recommend you go through the verification process + for each device before continuing, but you can resend + the message without verifying if you prefer. +

    +
    + ); + } + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + return ( + { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log("UnknownDeviceDialog closed by escape"); + this.props.onFinished(); + }} + title='Room contains unknown devices' + > + +

    + This room contains unknown devices which have not been + verified. +

    + { warning } + Unknown devices: + + +
    +
    + +
    +
    + ); + // XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point? + // It feels like confused users will likely turn it on and then disappear in a cloud of UISIs... + }, +}); diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js new file mode 100644 index 0000000000..ffea8e1ba7 --- /dev/null +++ b/src/components/views/elements/AccessibleButton.js @@ -0,0 +1,54 @@ +/* + Copyright 2016 Jani Mustonen + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import React from 'react'; + +/** + * AccessibleButton is a generic wrapper for any element that should be treated + * as a button. Identifies the element as a button, setting proper tab + * indexing and keyboard activation behavior. + * + * @param {Object} props react element properties + * @returns {Object} rendered react + */ +export default function AccessibleButton(props) { + const {element, onClick, children, ...restProps} = props; + restProps.onClick = onClick; + restProps.onKeyDown = function(e) { + if (e.keyCode == 13 || e.keyCode == 32) return onClick(); + }; + restProps.tabIndex = restProps.tabIndex || "0"; + restProps.role = "button"; + return React.createElement(element, restProps, children); +} + +/** + * children: React's magic prop. Represents all children given to the element. + * element: (optional) The base element type. "div" by default. + * onClick: (required) Event handler for button activation. Should be + * implemented exactly like a normal onClick handler. + */ +AccessibleButton.propTypes = { + children: React.PropTypes.node, + element: React.PropTypes.string, + onClick: React.PropTypes.func.isRequired, +}; + +AccessibleButton.defaultProps = { + element: 'div', +}; + +AccessibleButton.displayName = "AccessibleButton"; diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 2c2d7e2d61..9f37fa90ff 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -16,18 +16,24 @@ limitations under the License. 'use strict'; -var React = require("react"); -var sdk = require("../../../index"); -var classNames = require('classnames'); +import React from 'react'; +import sdk from '../../../index'; +import classNames from 'classnames'; +import { InviteAddressType } from './AddressTile'; -module.exports = React.createClass({ +export default React.createClass({ displayName: 'AddressSelector', propTypes: { onSelected: React.PropTypes.func.isRequired, - addressList: React.PropTypes.array.isRequired, + + // List of the addresses to display + addressList: React.PropTypes.arrayOf(InviteAddressType).isRequired, truncateAt: React.PropTypes.number.isRequired, selected: React.PropTypes.number, + + // Element to put as a header on top of the list + header: React.PropTypes.node, }, getInitialState: function() { @@ -55,7 +61,7 @@ module.exports = React.createClass({ } }, - onKeyUp: function() { + moveSelectionUp: function() { if (this.state.selected > 0) { this.setState({ selected: this.state.selected - 1, @@ -64,7 +70,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, @@ -73,25 +79,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() { @@ -124,8 +124,8 @@ module.exports = React.createClass({ // Saving the addressListElement so we can use it to work out, in the componentDidUpdate // method, how far to scroll when using the arrow keys addressList.push( -
    { this.addressListElement = ref; }} > - +
    { this.addressListElement = ref; }} > +
    ); } @@ -135,7 +135,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; }, @@ -146,7 +146,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 2799f10a41..18492d8ae6 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -23,16 +23,33 @@ var Invite = require("../../../Invite"); var MatrixClientPeg = require("../../../MatrixClientPeg"); var Avatar = require('../../../Avatar'); -module.exports = React.createClass({ +// React PropType definition for an object describing +// an address that can be invited to a room (which +// could be a third party identifier or a matrix ID) +// along with some additional information about the +// address / target. +export const InviteAddressType = React.PropTypes.shape({ + addressType: React.PropTypes.oneOf([ + 'mx', 'email' + ]).isRequired, + address: React.PropTypes.string.isRequired, + displayName: React.PropTypes.string, + avatarMxc: React.PropTypes.string, + // true if the address is known to be a valid address (eg. is a real + // user we've seen) or false otherwise (eg. is just an address the + // user has entered) + isKnown: React.PropTypes.bool, +}); + + +export default React.createClass({ displayName: 'AddressTile', propTypes: { - address: React.PropTypes.string.isRequired, + address: InviteAddressType.isRequired, canDismiss: React.PropTypes.bool, onDismissed: React.PropTypes.func, justified: React.PropTypes.bool, - networkName: React.PropTypes.string, - networkUrl: React.PropTypes.string, }, getDefaultProps: function() { @@ -40,37 +57,30 @@ module.exports = React.createClass({ canDismiss: false, onDismissed: function() {}, // NOP justified: false, - networkName: "", - networkUrl: "", }; }, render: function() { - var userId, name, imgUrl, email; - var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); + const address = this.props.address; + const name = address.displayName || address.address; - // Check if the addr is a valid type - var addrType = Invite.getAddressType(this.props.address); - if (addrType === "mx") { - let user = MatrixClientPeg.get().getUser(this.props.address); - if (user) { - userId = user.userId; - name = user.rawDisplayName || userId; - imgUrl = Avatar.avatarUrlForUser(user, 25, 25, "crop"); - } else { - name=this.props.address; - imgUrl = "img/icon-mx-user.svg"; - } - } else if (addrType === "email") { - email = this.props.address; - name="email"; - imgUrl = "img/icon-email-user.svg"; - } else { - name="Unknown"; - imgUrl = "img/avatar-error.svg"; + let imgUrl; + if (address.avatarMxc) { + imgUrl = MatrixClientPeg.get().mxcUrlToHttp( + address.avatarMxc, 25, 25, 'crop' + ); } + if (address.addressType === "mx") { + if (!imgUrl) imgUrl = 'img/icon-mx-user.svg'; + } else if (address.addressType === 'email') { + if (!imgUrl) imgUrl = 'img/icon-email-user.svg'; + } else { + if (!imgUrl) imgUrl = "img/avatar-error.svg"; + } + + // Removing networks for now as they're not really supported + /* var network; if (this.props.networkUrl !== "") { network = ( @@ -79,16 +89,20 @@ module.exports = React.createClass({
    ); } + */ - var info; - var error = false; - if (addrType === "mx" && userId) { - var nameClasses = classNames({ - "mx_AddressTile_name": true, - "mx_AddressTile_justified": this.props.justified, - }); + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const TintableSvg = sdk.getComponent("elements.TintableSvg"); - var idClasses = classNames({ + const nameClasses = classNames({ + "mx_AddressTile_name": true, + "mx_AddressTile_justified": this.props.justified, + }); + + let info; + let error = false; + if (address.addressType === "mx" && address.isKnown) { + const idClasses = classNames({ "mx_AddressTile_id": true, "mx_AddressTile_justified": this.props.justified, }); @@ -96,26 +110,34 @@ module.exports = React.createClass({ info = (
    { name }
    -
    { userId }
    +
    { address.address }
    ); - } else if (addrType === "mx") { - var unknownMxClasses = classNames({ + } else if (address.addressType === "mx") { + const unknownMxClasses = classNames({ "mx_AddressTile_unknownMx": true, "mx_AddressTile_justified": this.props.justified, }); info = ( -
    { this.props.address }
    +
    { this.props.address.address }
    ); - } else if (email) { - var emailClasses = classNames({ + } else if (address.addressType === "email") { + const emailClasses = classNames({ "mx_AddressTile_email": true, "mx_AddressTile_justified": this.props.justified, }); + let nameNode = null; + if (address.displayName) { + nameNode =
    { address.displayName }
    + } + info = ( -
    { email }
    +
    +
    { address.address }
    + {nameNode} +
    ); } else { error = true; @@ -129,12 +151,12 @@ module.exports = React.createClass({ ); } - var classes = classNames({ + const classes = classNames({ "mx_AddressTile": true, "mx_AddressTile_error": error, }); - var dismiss; + let dismiss; if (this.props.canDismiss) { dismiss = (
    @@ -145,7 +167,6 @@ module.exports = React.createClass({ return (
    - { network }
    diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js index aeb93e866c..fdd34e6ad2 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -27,6 +27,28 @@ export default React.createClass({ device: React.PropTypes.object.isRequired, }, + getInitialState: function() { + return { + device: this.props.device + }; + }, + + componentWillMount: function() { + const cli = MatrixClientPeg.get(); + cli.on("deviceVerificationChanged", this.onDeviceVerificationChanged); + }, + + componentWillUnmount: function() { + const cli = MatrixClientPeg.get(); + cli.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); + }, + + onDeviceVerificationChanged: function(userId, deviceId, deviceInfo) { + if (userId === this.props.userId && deviceId === this.props.device.deviceId) { + this.setState({ device: deviceInfo }); + } + }, + onVerifyClick: function() { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { @@ -41,9 +63,9 @@ export default React.createClass({

      -
    • { this.props.device.getDisplayName() }
    • -
    • { this.props.device.deviceId}
    • -
    • { this.props.device.getFingerprint() }
    • +
    • { this.state.device.getDisplayName() }
    • +
    • { this.state.device.deviceId}
    • +
    • { this.state.device.getFingerprint() }

    @@ -60,7 +82,7 @@ export default React.createClass({ onFinished: confirm=>{ if (confirm) { MatrixClientPeg.get().setDeviceVerified( - this.props.userId, this.props.device.deviceId, true + this.props.userId, this.state.device.deviceId, true ); } }, @@ -69,26 +91,26 @@ export default React.createClass({ onUnverifyClick: function() { MatrixClientPeg.get().setDeviceVerified( - this.props.userId, this.props.device.deviceId, false + this.props.userId, this.state.device.deviceId, false ); }, onBlacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( - this.props.userId, this.props.device.deviceId, true + this.props.userId, this.state.device.deviceId, true ); }, onUnblacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( - this.props.userId, this.props.device.deviceId, false + this.props.userId, this.state.device.deviceId, false ); }, render: function() { var blacklistButton = null, verifyButton = null; - if (this.props.device.isBlocked()) { + if (this.state.device.isBlocked()) { blacklistButton = (

    +
    ; } const memberName = this.props.member.name; @@ -681,7 +694,7 @@ module.exports = WithMatrixClient(React.createClass({ const EmojiText = sdk.getComponent('elements.EmojiText'); return (
    - +
    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..9aab174511 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -40,6 +40,7 @@ import * as HtmlUtils from '../../../HtmlUtils'; import Autocomplete from './Autocomplete'; import {Completion} from "../../../autocomplete/Autocompleter"; import Markdown from '../../../Markdown'; +import {onSendMessageFailed} from './MessageComposerInputOld'; const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; @@ -443,12 +444,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 +463,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 +525,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(); } } @@ -550,15 +554,11 @@ export default class MessageComposerInput extends React.Component { sendMessagePromise = sendTextFn.call(this.client, this.props.room.roomId, contentText); } - sendMessagePromise.then(() => { + sendMessagePromise.done((res) => { dis.dispatch({ action: 'message_sent', }); - }, () => { - dis.dispatch({ - action: 'message_send_failed', - }); - }); + }, (e) => onSendMessageFailed(e, this.props.room)); this.setState({ editorState: this.createEditorState(), @@ -663,7 +663,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 +716,7 @@ export default class MessageComposerInput extends React.Component { selection={selection} />
    - @@ -738,7 +738,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..020c2238cb 100644 --- a/src/components/views/rooms/MessageComposerInputOld.js +++ b/src/components/views/rooms/MessageComposerInputOld.js @@ -29,10 +29,31 @@ var TYPING_USER_TIMEOUT = 10000; var TYPING_SERVER_TIMEOUT = 30000; var MARKDOWN_ENABLED = true; +export function onSendMessageFailed(err, room) { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('MessageComposer got send failure: ' + err.name + '('+err+')'); + if (err.name === "UnknownDeviceError") { + const UnknownDeviceDialog = sdk.getComponent("dialogs.UnknownDeviceDialog"); + Modal.createDialog(UnknownDeviceDialog, { + devices: err.devices, + room: room, + onFinished: (r) => { + // XXX: temporary logging to try to diagnose + // https://github.com/vector-im/riot-web/issues/3148 + console.log('UnknownDeviceDialog closed with '+r); + }, + }, "mx_Dialog_unknownDevice"); + } + dis.dispatch({ + action: 'message_send_failed', + }); +} + /* * The textInput part of the MessageComposer */ -module.exports = React.createClass({ +export default React.createClass({ displayName: 'MessageComposerInput', statics: { @@ -192,7 +213,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,20 +352,18 @@ module.exports = React.createClass({ MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); } else { + if (mdown) contentText = mdown.toPlaintext(); sendMessagePromise = isEmote ? MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); } - sendMessagePromise.done(function() { + sendMessagePromise.done(function(res) { dis.dispatch({ action: 'message_sent' }); - }, function() { - dis.dispatch({ - action: 'message_send_failed' - }); - }); + }, (e) => onSendMessageFailed(e, this.props.room)); + this.refs.textarea.value = ''; this.resizeInput(); ev.preventDefault(); diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js index 47875bd7fb..230efbd1ea 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() { @@ -170,15 +170,15 @@ module.exports = React.createClass({ let title; if (this.props.timestamp) { - let suffix = " (" + this.props.member.userId + ")"; + const prefix = "Seen by " + this.props.member.userId + " at "; let ts = new Date(this.props.timestamp); if (this.props.showFullTimestamp) { // "15/12/2016, 7:05:45 PM (@alice:matrix.org)" - title = ts.toLocaleString() + suffix; + title = prefix + ts.toLocaleString(); } else { // "7:05:45 PM (@alice:matrix.org)" - title = ts.toLocaleTimeString() + suffix; + title = prefix + ts.toLocaleTimeString(); } } diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index db3c7bb3d9..1a8776cd96 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -26,6 +26,8 @@ var rate_limited_func = require('../../../ratelimitedfunc'); var linkify = require('linkifyjs'); var linkifyElement = require('linkifyjs/element'); var linkifyMatrix = require('../../../linkify-matrix'); +import AccessibleButton from '../elements/AccessibleButton'; +import {CancelButton} from './SimpleRoomHeader'; linkifyMatrix(linkify); @@ -182,8 +184,8 @@ module.exports = React.createClass({ 'm.room.name', user_id ); - save_button =
    Save
    - cancel_button =
    Cancel
    + save_button = Save; + cancel_button = ; } if (this.props.saving) { @@ -193,7 +195,7 @@ module.exports = React.createClass({ if (can_set_room_name) { var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor"); - name = + name = ; } else { var searchStatus; @@ -232,7 +234,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) { @@ -275,9 +277,9 @@ module.exports = React.createClass({ var settings_button; if (this.props.onSettingsClick) { settings_button = -
    + -
    ; + ; } // var leave_button; @@ -291,17 +293,17 @@ module.exports = React.createClass({ var forget_button; if (this.props.onForgetClick) { forget_button = -
    + -
    ; + ; } var rightPanel_buttons; if (this.props.collapsedRhs) { rightPanel_buttons = -
    - -
    + + + ; } var right_row; @@ -310,9 +312,9 @@ module.exports = React.createClass({
    { settings_button } { forget_button } -
    + -
    + { rightPanel_buttons }
    ; } 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..51ae0fe965 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 = ( @@ -146,7 +146,7 @@ module.exports = React.createClass({
    You are trying to access { name }.
    - Would you like to join in order to participate in the discussion? + Click here to join the discussion!
    ); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 04ea05843d..a23368f5e8 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -24,6 +24,8 @@ var ObjectUtils = require("../../../ObjectUtils"); var dis = require("../../../dispatcher"); var ScalarAuthClient = require("../../../ScalarAuthClient"); var ScalarMessaging = require('../../../ScalarMessaging'); +var UserSettingsStore = require('../../../UserSettingsStore'); + // parse a string as an integer; if the input is undefined, or cannot be parsed // as an integer, return a default. @@ -228,11 +230,13 @@ module.exports = React.createClass({ } // encryption - p = this.saveEncryption(); + p = this.saveEnableEncryption(); if (!q.isFulfilled(p)) { promises.push(p); } + this.saveBlacklistUnverifiedDevicesPerRoom(); + console.log("Performing %s operations: %s", promises.length, JSON.stringify(promises)); return promises; }, @@ -252,7 +256,7 @@ module.exports = React.createClass({ return this.refs.url_preview_settings.saveSettings(); }, - saveEncryption: function () { + saveEnableEncryption: function() { if (!this.refs.encrypt) { return q(); } var encrypt = this.refs.encrypt.checked; @@ -265,6 +269,29 @@ module.exports = React.createClass({ ); }, + saveBlacklistUnverifiedDevicesPerRoom: function() { + if (!this.refs.blacklistUnverified) return; + if (this._isRoomBlacklistUnverified() !== this.refs.blacklistUnverified.checked) { + this._setRoomBlacklistUnverified(this.refs.blacklistUnverified.checked); + } + }, + + _isRoomBlacklistUnverified: function() { + var blacklistUnverifiedDevicesPerRoom = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevicesPerRoom; + if (blacklistUnverifiedDevicesPerRoom) { + return blacklistUnverifiedDevicesPerRoom[this.props.room.roomId]; + } + return false; + }, + + _setRoomBlacklistUnverified: function(value) { + var blacklistUnverifiedDevicesPerRoom = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevicesPerRoom || {}; + blacklistUnverifiedDevicesPerRoom[this.props.room.roomId] = value; + UserSettingsStore.setLocalSetting('blacklistUnverifiedDevicesPerRoom', blacklistUnverifiedDevicesPerRoom); + + this.props.room.setBlacklistUnverifiedDevices(value); + }, + _hasDiff: function(strA, strB) { // treat undefined as an empty string because other components may blindly // call setName("") when there has been no diff made to the name! @@ -404,7 +431,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) { @@ -477,26 +504,42 @@ module.exports = React.createClass({ var cli = MatrixClientPeg.get(); var roomState = this.props.room.currentState; var isEncrypted = cli.isRoomEncrypted(this.props.room.roomId); + var isGlobalBlacklistUnverified = UserSettingsStore.getLocalSettings().blacklistUnverifiedDevices; + var isRoomBlacklistUnverified = this._isRoomBlacklistUnverified(); + + var settings = + ; if (!isEncrypted && roomState.mayClientSendStateEvent("m.room.encryption", cli)) { return ( - +
    + + { settings } +
    ); } else { return ( - +
    + + { settings } +
    ); } }, @@ -510,7 +553,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 +600,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 +678,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 +696,7 @@ module.exports = React.createClass({ addressWarning =
    To link to a room it must have an address. -
    + ; } var inviteGuestWarning; @@ -664,7 +707,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..f6c0f7034e 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -26,6 +26,8 @@ var sdk = require('../../../index'); var ContextualMenu = require('../../structures/ContextualMenu'); var RoomNotifs = require('../../../RoomNotifs'); var FormattingUtils = require('../../../utils/FormattingUtils'); +import AccessibleButton from '../elements/AccessibleButton'; +var UserSettingsStore = require('../../../UserSettingsStore'); module.exports = React.createClass({ displayName: 'RoomTile', @@ -176,7 +178,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 +222,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, @@ -286,8 +289,10 @@ module.exports = React.createClass({ var connectDragSource = this.props.connectDragSource; var connectDropTarget = this.props.connectDropTarget; + let ret = ( -
    +
    { /* Only native elements can be wrapped in a DnD object. */} +
    @@ -302,6 +307,7 @@ module.exports = React.createClass({
    {/* { incomingCallBox } */} { tooltip } +
    ); 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..40995d2a72 100644 --- a/src/components/views/rooms/SimpleRoomHeader.js +++ b/src/components/views/rooms/SimpleRoomHeader.js @@ -16,15 +16,27 @@ limitations under the License. 'use strict'; -var React = require('react'); -var sdk = require('../../../index'); -var dis = require("../../../dispatcher"); +import React from 'react'; +import dis from '../../../dispatcher'; +import AccessibleButton from '../elements/AccessibleButton'; + +// cancel button which is shared between room header and simple room header +export function CancelButton(props) { + const {onClick} = props; + + return ( + + Cancel + + ); +} /* * A stripped-down room header used for things like the user settings * and room directory. */ -module.exports = React.createClass({ +export default React.createClass({ displayName: 'SimpleRoomHeader', propTypes: { @@ -40,15 +52,15 @@ module.exports = React.createClass({ }, render: function() { - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - - var cancelButton; + let cancelButton; if (this.props.onCancelClick) { - cancelButton =
    Cancel
    + cancelButton = ; } - var showRhsButton; + let showRhsButton; /* // don't bother cluttering things up with this for now. + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + if (this.props.collapsedRhs) { showRhsButton =
    @@ -70,4 +82,3 @@ module.exports = React.createClass({ ); }, }); - 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..8b53a0e779 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -18,7 +18,9 @@ limitations under the License. var React = require('react'); var MatrixClientPeg = require("../../../MatrixClientPeg"); +var Modal = require("../../../Modal"); var sdk = require("../../../index"); +import AccessibleButton from '../elements/AccessibleButton'; module.exports = React.createClass({ displayName: 'ChangePassword', @@ -59,32 +61,48 @@ module.exports = React.createClass({ getInitialState: function() { return { phase: this.Phases.Edit - } + }; }, changePassword: function(old_password, new_password) { var cli = MatrixClientPeg.get(); - var authDict = { - type: 'm.login.password', - user: cli.credentials.userId, - password: old_password - }; + var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Warning", + description: +
    + Changing password will currently reset any end-to-end encryption keys on all devices, + making encrypted chat history unreadable. + This will be improved shortly, + but for now be warned. +
    , + button: "Continue", + onFinished: (confirmed) => { + if (confirmed) { + var authDict = { + type: 'm.login.password', + user: cli.credentials.userId, + password: old_password + }; - this.setState({ - phase: this.Phases.Uploading + this.setState({ + phase: this.Phases.Uploading + }); + + var self = this; + cli.setPassword(authDict, new_password).then(function() { + self.props.onFinished(); + }, function(err) { + self.props.onError(err); + }).finally(function() { + self.setState({ + phase: self.Phases.Edit + }); + }).done(); + } + }, }); - - var self = this; - cli.setPassword(authDict, new_password).then(function() { - self.props.onFinished(); - }, function(err) { - self.props.onError(err); - }).finally(function() { - self.setState({ - phase: self.Phases.Edit - }); - }).done(); }, onClickChange: function() { @@ -105,7 +123,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) { @@ -136,9 +154,10 @@ module.exports = React.createClass({
    -
    + Change Password -
    + ); case this.Phases.Uploading: 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..9864cb3807 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -39,8 +39,11 @@ class MatrixDispatcher extends flux.Dispatcher { setTimeout(super.dispatch.bind(this, payload), 0); } } -}; +} +// XXX this is a big anti-pattern, and makes testing hard. Because dispatches +// happen asynchronously, it is possible for actions dispatched in one thread +// to arrive in another, with *hilarious* consequences. if (global.mxDispatcher === undefined) { global.mxDispatcher = new MatrixDispatcher(); } diff --git a/src/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..b6d8c0b5f4 100644 --- a/src/index.js +++ b/src/index.js @@ -27,4 +27,3 @@ module.exports.resetSkin = function() { module.exports.getComponent = function(componentName) { return Skinner.getComponent(componentName); }; - 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..c98c467e1c --- /dev/null +++ b/src/utils/MegolmExportEncryption.js @@ -0,0 +1,323 @@ +/* +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 (ciphertextLength < 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 || 500000; + + const salt = new Uint8Array(16); + window.crypto.getRandomValues(salt); + + const iv = new Uint8Array(16); + window.crypto.getRandomValues(iv); + + // clear bit 63 of the IV 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 iv is a price we have to pay. + iv[9] &= 0x7f; + + 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) { + const start = new Date(); + 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 now = new Date(); + console.log("E2e import/export: deriveKeys took " + (now - start) + "ms"); + + 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/structures/RoomView-test.js b/test/components/structures/RoomView-test.js index 58db29b1ee..8e7c8160b8 100644 --- a/test/components/structures/RoomView-test.js +++ b/test/components/structures/RoomView-test.js @@ -42,17 +42,12 @@ describe('RoomView', function () { it('resolves a room alias to a room id', function (done) { peg.get().getRoomIdForAlias.returns(q({room_id: "!randomcharacters:aser.ver"})); - var onRoomIdResolved = sinon.spy(); + function onRoomIdResolved(room_id) { + expect(room_id).toEqual("!randomcharacters:aser.ver"); + done(); + } ReactDOM.render(, parentDiv); - - process.nextTick(function() { - // These expect()s don't read very well and don't give very good failure - // messages, but expect's toHaveBeenCalled only takes an expect spy object, - // not a sinon spy object. - expect(onRoomIdResolved.called).toExist(); - done(); - }); }); it('joins by alias if given an alias', function (done) { diff --git a/test/components/structures/ScrollPanel-test.js b/test/components/structures/ScrollPanel-test.js index 13721c9ecd..eacaeb5fb4 100644 --- a/test/components/structures/ScrollPanel-test.js +++ b/test/components/structures/ScrollPanel-test.js @@ -73,6 +73,7 @@ var Tester = React.createClass({ /* returns a promise which will resolve when the fill happens */ awaitFill: function(dir) { + console.log("ScrollPanel Tester: awaiting " + dir + " fill"); var defer = q.defer(); this._fillDefers[dir] = defer; return defer.promise; @@ -80,7 +81,7 @@ var Tester = React.createClass({ _onScroll: function(ev) { var st = ev.target.scrollTop; - console.log("Scroll event; scrollTop: " + st); + console.log("ScrollPanel Tester: scroll event; scrollTop: " + st); this.lastScrollEvent = st; var d = this._scrollDefer; @@ -159,10 +160,29 @@ describe('ScrollPanel', function() { scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass( tester, "gm-scroll-view"); - // wait for a browser tick to let the initial paginates complete - setTimeout(function() { - done(); - }, 0); + // we need to make sure we don't call done() until q has finished + // running the completion handlers from the fill requests. We can't + // just use .done(), because that will end up ahead of those handlers + // in the queue. We can't use window.setTimeout(0), because that also might + // run ahead of those handlers. + const sp = tester.scrollPanel(); + let retriesRemaining = 1; + const awaitReady = function() { + return q().then(() => { + if (sp._pendingFillRequests.b === false && + sp._pendingFillRequests.f === false + ) { + return; + } + + if (retriesRemaining == 0) { + throw new Error("fillRequests did not complete"); + } + retriesRemaining--; + return awaitReady(); + }); + }; + awaitReady().done(done); }); afterEach(function() { diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index b2cdfbd590..be60691b5c 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -99,7 +99,11 @@ describe('TimelinePanel', function() { // the document so that we can interact with it properly. parentDiv = document.createElement('div'); parentDiv.style.width = '800px'; - parentDiv.style.height = '600px'; + + // This has to be slightly carefully chosen. We expect to have to do + // exactly one pagination to fill it. + parentDiv.style.height = '500px'; + parentDiv.style.overflow = 'hidden'; document.body.appendChild(parentDiv); }); @@ -235,7 +239,7 @@ describe('TimelinePanel', function() { expect(client.paginateEventTimeline.callCount).toEqual(0); done(); }, 0); - }, 0); + }, 10); }); it("should let you scroll down to the bottom after you've scrolled up", function(done) { diff --git a/test/components/views/elements/MemberEventListSummary-test.js b/test/components/views/elements/MemberEventListSummary-test.js new file mode 100644 index 0000000000..d01d705040 --- /dev/null +++ b/test/components/views/elements/MemberEventListSummary-test.js @@ -0,0 +1,681 @@ +const expect = require('expect'); +const React = require('react'); +const ReactDOM = require("react-dom"); +const ReactTestUtils = require('react-addons-test-utils'); +const sdk = require('matrix-react-sdk'); +const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); + +const testUtils = require('../../../test-utils'); +describe('MemberEventListSummary', function() { + let sandbox; + + // Generate dummy event tiles for use in simulating an expanded MELS + const generateTiles = (events) => { + return events.map((e) => { + return ( +
    + Expanded membership +
    + ); + }); + }; + + /** + * Generates a membership event with the target of the event set as a mocked + * RoomMember based on `parameters.userId`. + * @param {string} eventId the ID of the event. + * @param {object} parameters the parameters to use to create the event. + * @param {string} parameters.membership the membership to assign to + * `content.membership` + * @param {string} parameters.userId the state key and target userId of the event. If + * `parameters.senderId` is not specified, this is also used as the event sender. + * @param {string} parameters.prevMembership the membership to assign to + * `prev_content.membership`. + * @param {string} parameters.senderId the user ID of the sender of the event. + * Optional. Defaults to `parameters.userId`. + * @returns {MatrixEvent} the event created. + */ + const generateMembershipEvent = (eventId, parameters) => { + const e = testUtils.mkMembership({ + event: true, + user: parameters.senderId || parameters.userId, + skey: parameters.userId, + mship: parameters.membership, + prevMship: parameters.prevMembership, + target: { + // Use localpart as display name + name: parameters.userId.match(/@([^:]*):/)[1], + userId: parameters.userId, + getAvatarUrl: () => { + return "avatar.jpeg"; + }, + }, + }); + // Override random event ID to allow for equality tests against tiles from + // generateTiles + e.event.event_id = eventId; + return e; + }; + + // Generate mock MatrixEvents from the array of parameters + const generateEvents = (parameters) => { + const res = []; + for (let i = 0; i < parameters.length; i++) { + res.push(generateMembershipEvent(`event${i}`, parameters[i])); + } + return res; + }; + + // Generate the same sequence of `events` for `n` users, where each user ID + // is created by replacing the first "$" in userIdTemplate with `i` for + // `i = 0 .. n`. + const generateEventsForUsers = (userIdTemplate, n, events) => { + let eventsForUsers = []; + let userId = ""; + for (let i = 0; i < n; i++) { + userId = userIdTemplate.replace('$', i); + events.forEach((e) => { + e.userId = userId; + }); + eventsForUsers = eventsForUsers.concat(generateEvents(events)); + } + return eventsForUsers; + }; + + beforeEach(function() { + testUtils.beforeEach(this); + sandbox = testUtils.stubClient(); + }); + + afterEach(function() { + sandbox.restore(); + }); + + it('renders expanded events if there are less than props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const renderer = ReactTestUtils.createRenderer(); + renderer.render(); + const result = renderer.getRenderOutput(); + + expect(result.type).toBe('div'); + expect(result.props.children).toEqual([ +
    Expanded membership
    , + ]); + }); + + it('renders expanded events if there are less than props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const renderer = ReactTestUtils.createRenderer(); + renderer.render(); + const result = renderer.getRenderOutput(); + + expect(result.type).toBe('div'); + expect(result.props.children).toEqual([ +
    Expanded membership
    , +
    Expanded membership
    , + ]); + }); + + it('renders collapsed events if events.length = props.threshold', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe("user_1 joined and left and joined"); + }); + + it('truncates long join,leave repetitions', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe("user_1 joined and left 7 times"); + }); + + it('truncates long join,leave repetitions between other events', function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "invite", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was unbanned, joined and left 7 times and was invited" + ); + }); + + it('truncates multiple sequences of repetitions with other events between', + function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "ban", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "invite", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was unbanned, joined and left 2 times, was banned, " + + "joined and left 3 times and was invited" + ); + }); + + it('handles multiple users following the same sequence of memberships', function() { + const events = generateEvents([ + // user_1 + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + // user_2 + { + userId: "@user_2:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_2:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and 1 other were unbanned, joined and left 2 times and were banned" + ); + }); + + it('handles many users following the same sequence of memberships', function() { + const events = generateEventsForUsers("@user_$:some.domain", 20, [ + { + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {prevMembership: "leave", membership: "join"}, + {prevMembership: "join", membership: "leave"}, + {prevMembership: "leave", membership: "join"}, + {prevMembership: "join", membership: "leave"}, + { + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_0 and 19 others were unbanned, joined and left 2 times and were banned" + ); + }); + + it('correctly orders sequences of transitions by the order of their first event', + function() { + const events = generateEvents([ + { + userId: "@user_2:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_1:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + { + userId: "@user_1:some.domain", + prevMembership: "leave", + membership: "ban", + senderId: "@some_other_user:some.domain", + }, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + {userId: "@user_2:some.domain", prevMembership: "leave", membership: "join"}, + {userId: "@user_2:some.domain", prevMembership: "join", membership: "leave"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_2 was unbanned and joined and left 2 times, user_1 was unbanned, " + + "joined and left 2 times and was banned" + ); + }); + + it('correctly identifies transitions', function() { + const events = generateEvents([ + // invited + {userId: "@user_1:some.domain", membership: "invite"}, + // banned + {userId: "@user_1:some.domain", membership: "ban"}, + // joined + {userId: "@user_1:some.domain", membership: "join"}, + // invite_reject + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + // left + {userId: "@user_1:some.domain", prevMembership: "join", membership: "leave"}, + // invite_withdrawal + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // unbanned + { + userId: "@user_1:some.domain", + prevMembership: "ban", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // kicked + { + userId: "@user_1:some.domain", + prevMembership: "join", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + // default = left + { + userId: "@user_1:some.domain", + prevMembership: "????", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 was invited, was banned, joined, rejected their invitation, left, " + + "had their invitation withdrawn, was unbanned, was kicked and left" + ); + }); + + it('handles invitation plurals correctly when there are multiple users', function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + { + userId: "@user_2:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_2:some.domain", + prevMembership: "invite", + membership: "leave", + senderId: "@some_other_user:some.domain", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and 1 other rejected their invitations and " + + "had their invitations withdrawn" + ); + }); + + it('handles invitation plurals correctly when there are multiple invites', + function() { + const events = generateEvents([ + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + { + userId: "@user_1:some.domain", + prevMembership: "invite", + membership: "leave", + }, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 1, + avatarsMaxLength: 5, + threshold: 1, // threshold = 1 to force collapse + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 rejected their invitations 2 times" + ); + }); + + it('handles a summary length = 2, with no "others"', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1 and user_2 joined 2 times" + ); + }); + + it('handles a summary length = 2, with 1 "other"', function() { + const events = generateEvents([ + {userId: "@user_1:some.domain", membership: "join"}, + {userId: "@user_2:some.domain", membership: "join"}, + {userId: "@user_3:some.domain", membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_1, user_2 and 1 other joined" + ); + }); + + it('handles a summary length = 2, with many "others"', function() { + const events = generateEventsForUsers("@user_$:some.domain", 20, [ + {membership: "join"}, + ]); + const props = { + events: events, + children: generateTiles(events), + summaryLength: 2, + avatarsMaxLength: 5, + threshold: 3, + }; + + const instance = ReactTestUtils.renderIntoDocument( + + ); + const summary = ReactTestUtils.findRenderedDOMComponentWithClass( + instance, "mx_MemberEventListSummary_summary" + ); + const summaryText = summary.innerText; + + expect(summaryText).toBe( + "user_0, user_1 and 18 others joined" + ); + }); +}); diff --git a/test/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/test-utils.js b/test/test-utils.js index db405c2e1a..71d3bd92d6 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -14,7 +14,15 @@ var MatrixEvent = jssdk.MatrixEvent; */ export function beforeEach(context) { var desc = context.currentTest.fullTitle(); + console.log(); + + // this puts a mark in the chrome devtools timeline, which can help + // figure out what's been going on. + if (console.timeStamp) { + console.timeStamp(desc); + } + console.log(desc); console.log(new Array(1 + desc.length).join("=")); }; @@ -108,6 +116,7 @@ export function mkEvent(opts) { room_id: opts.room, sender: opts.user, content: opts.content, + prev_content: opts.prev_content, event_id: "$" + Math.random() + "-" + Math.random(), origin_server_ts: opts.ts, }; @@ -150,7 +159,9 @@ export function mkPresence(opts) { * @param {Object} opts Values for the membership. * @param {string} opts.room The room ID for the event. * @param {string} opts.mship The content.membership for the event. + * @param {string} opts.prevMship The prev_content.membership for the event. * @param {string} opts.user The user ID for the event. + * @param {RoomMember} opts.target The target of the event. * @param {string} opts.skey The other user ID for the event if applicable * e.g. for invites/bans. * @param {string} opts.name The content.displayname for the event. @@ -169,9 +180,16 @@ export function mkMembership(opts) { opts.content = { membership: opts.mship }; + if (opts.prevMship) { + opts.prev_content = { membership: opts.prevMship }; + } if (opts.name) { opts.content.displayname = opts.name; } if (opts.url) { opts.content.avatar_url = opts.url; } - return mkEvent(opts); + let e = mkEvent(opts); + if (opts.target) { + e.target = opts.target; + } + return e; }; /** diff --git a/test/utils/MegolmExportEncryption-test.js b/test/utils/MegolmExportEncryption-test.js new file mode 100644 index 0000000000..0c49fd48d1 --- /dev/null +++ b/test/utils/MegolmExportEncryption-test.js @@ -0,0 +1,126 @@ +/* +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 handle a too-short body', function() { + const input=stringToArray(`-----BEGIN MEGOLM SESSION DATA----- +AXNhbHRzYWx0c2FsdHNhbHSIiIiIiIiIiIiIiIiIiIiIAAAACmIRUW2OjZ3L2l6j9h0lHlV3M2dx +cissyYBxjsfsAn +-----END MEGOLM SESSION DATA----- +`); + expect(()=>{MegolmExportEncryption.decryptMegolmKeyFile(input, '')}) + .toThrow('Invalid file: too short'); + }); + + 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))