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..92280344fa --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,75 @@ +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*<', + }], + "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 57d6c38d01..4f1e33e61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,166 @@ +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) + + * Fix signup by working around the fact that reCapture doesn't work on electron + * Fix windows shortcut link + +Changes in [0.8.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.3) (2016-12-22) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.2...v0.8.3) + + * Revert performance fix for wantsDateSeperator which was causing date separators to + be shown at the wrong time of day. + * Unbranded error messages + [\#599](https://github.com/matrix-org/matrix-react-sdk/pull/599) + * Fix scroll jumping when a video is decrypted + [\#594](https://github.com/matrix-org/matrix-react-sdk/pull/594) + +Changes in [0.8.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.2) (2016-12-16) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.1...v0.8.2) + + * Improve the performance of MemberEventListSummary + [\#590](https://github.com/matrix-org/matrix-react-sdk/pull/590) + * Implement bulk invite rejections + [\#592](https://github.com/matrix-org/matrix-react-sdk/pull/592) + * Fix performance issues with wantsDateSeperator + [\#591](https://github.com/matrix-org/matrix-react-sdk/pull/591) + * Add read receipt times to the hovertip of read markers + [\#586](https://github.com/matrix-org/matrix-react-sdk/pull/586) + * Don't throw exception on stop if no DMRoomMap + [\#589](https://github.com/matrix-org/matrix-react-sdk/pull/589) + * Fix failing test + [\#587](https://github.com/matrix-org/matrix-react-sdk/pull/587) + +Changes in [0.8.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.1) (2016-12-09) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.1-rc.2...v0.8.1) + +No changes + +Changes in [0.8.1-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.1-rc.2) (2016-12-06) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.1-rc.1...v0.8.1-rc.2) + + * Fix exception when clearing room dir search + [\#585](https://github.com/matrix-org/matrix-react-sdk/pull/585) + * Allow integration UI URLs with paths + [\#583](https://github.com/matrix-org/matrix-react-sdk/pull/583) + * Give the search box field a name + [\#584](https://github.com/matrix-org/matrix-react-sdk/pull/584) + * Pass the room object into displayNotification + [\#582](https://github.com/matrix-org/matrix-react-sdk/pull/582) + * Don't throw an exception entering settings page + [\#581](https://github.com/matrix-org/matrix-react-sdk/pull/581) + +Changes in [0.8.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.1-rc.1) (2016-12-05) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.0...v0.8.1-rc.1) + + * Strip (IRC) when clicking on username + [\#579](https://github.com/matrix-org/matrix-react-sdk/pull/579) + * Fix scroll jump on image decryption + [\#577](https://github.com/matrix-org/matrix-react-sdk/pull/577) + * Make cut operations update the tab complete list + [\#576](https://github.com/matrix-org/matrix-react-sdk/pull/576) + * s/block/blacklist for e2e + [\#574](https://github.com/matrix-org/matrix-react-sdk/pull/574) + * Fix the download icon on attachments + [\#573](https://github.com/matrix-org/matrix-react-sdk/pull/573) + * Don't default the page_type to room directory + [\#572](https://github.com/matrix-org/matrix-react-sdk/pull/572) + * Fix crash on logging in + [\#571](https://github.com/matrix-org/matrix-react-sdk/pull/571) + * Reinstate missing sections from the UserSettings + [\#569](https://github.com/matrix-org/matrix-react-sdk/pull/569) + * Bump browser-encrypt-attachment to v0.2.0 + [\#568](https://github.com/matrix-org/matrix-react-sdk/pull/568) + * Make the unpagination process less aggressive + [\#567](https://github.com/matrix-org/matrix-react-sdk/pull/567) + * Get rid of always-on labs settings + [\#566](https://github.com/matrix-org/matrix-react-sdk/pull/566) + * Fix 'Quote' for e2e messages + [\#565](https://github.com/matrix-org/matrix-react-sdk/pull/565) + +Changes in [0.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.8.0) (2016-11-19) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.7.5...v0.8.0) + + * Fix more membership change collapsing bugs + [\#560](https://github.com/matrix-org/matrix-react-sdk/pull/560) + * Show an open padlock for unencrypted rooms + [\#557](https://github.com/matrix-org/matrix-react-sdk/pull/557) + * Clean up MFileBody.presentableTextForFile + [\#558](https://github.com/matrix-org/matrix-react-sdk/pull/558) + * Update eventtiles when the events are decrypted + [\#556](https://github.com/matrix-org/matrix-react-sdk/pull/556) + * Update EventTile to use WithMatrixClient instead of MatrixClientPeg + [\#552](https://github.com/matrix-org/matrix-react-sdk/pull/552) + * Disable conference calling for encrypted rooms + [\#549](https://github.com/matrix-org/matrix-react-sdk/pull/549) + * Encrypt attachments in encrypted rooms + [\#548](https://github.com/matrix-org/matrix-react-sdk/pull/548) + * Fix MemberAvatar PropTypes & MemberEventListSummary key + [\#547](https://github.com/matrix-org/matrix-react-sdk/pull/547) + * Revert "Encrypt attachments in encrypted rooms," + [\#546](https://github.com/matrix-org/matrix-react-sdk/pull/546) + * Fix the vector web version in UserSettings + [\#542](https://github.com/matrix-org/matrix-react-sdk/pull/542) + * Truncate consecutive member events + [\#544](https://github.com/matrix-org/matrix-react-sdk/pull/544) + * Encrypt attachments in encrypted rooms, + [\#533](https://github.com/matrix-org/matrix-react-sdk/pull/533) + * Fix the ctrl+e mute camera shortcut + [\#545](https://github.com/matrix-org/matrix-react-sdk/pull/545) + * Show the error that occured when trying to reach scalar + [\#543](https://github.com/matrix-org/matrix-react-sdk/pull/543) + * Don't do URL previews for matrix.to + [\#541](https://github.com/matrix-org/matrix-react-sdk/pull/541) + * Fix NPE in LoggedInView + [\#540](https://github.com/matrix-org/matrix-react-sdk/pull/540) + * Make room alias & user ID links matrix.to links + [\#538](https://github.com/matrix-org/matrix-react-sdk/pull/538) + * Make MemberInfo use the matrixclient from the context + [\#537](https://github.com/matrix-org/matrix-react-sdk/pull/537) + * Add the MatrixClient to the react context + [\#536](https://github.com/matrix-org/matrix-react-sdk/pull/536) + * Factor out LoggedInView from MatrixChat + [\#535](https://github.com/matrix-org/matrix-react-sdk/pull/535) + * Move 'new version' support into Platform + [\#532](https://github.com/matrix-org/matrix-react-sdk/pull/532) + * Move Notifications into Platform + [\#534](https://github.com/matrix-org/matrix-react-sdk/pull/534) + * Move platform-specific functionality into Platform + [\#531](https://github.com/matrix-org/matrix-react-sdk/pull/531) + Changes in [0.7.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.7.5) (2016-11-04) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.7.5-rc.1...v0.7.5) diff --git a/README.md b/README.md index dfc1a6e6ec..3627225299 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,17 @@ a 'skin'. A skin provides: * Zero or more 'modules' containing non-UI functionality **WARNING: As of July 2016, the skinning abstraction is broken due to rapid -development of `matrix-react-sdk` to meet the needs of Vector, the first app -to be built on top of the SDK** (https://github.com/vector-im/vector-web). -Right now `matrix-react-sdk` depends on some functionality from `vector-web` -(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour -(grep for 'vector'). This layering will be fixed asap once Vector development +development of `matrix-react-sdk` to meet the needs of Riot (codenamed Vector), the first app +to be built on top of the SDK** (https://github.com/vector-im/riot-web). +Right now `matrix-react-sdk` depends on some functionality from `riot-web` +(e.g. CSS), and `matrix-react-sdk` contains some Riot specific behaviour +(grep for 'vector'). This layering will be fixed asap once Riot development has stabilised, but for now we do not advise trying to create new skins for matrix-react-sdk until the layers are clearly separated again. -In the interim, `vector-im/vector-web` and `matrix-org/matrix-react-sdk` should +In the interim, `vector-im/riot-web` and `matrix-org/matrix-react-sdk` should be considered as a single project (for instance, matrix-react-sdk bugs -are currently filed against vector-im/vector-web rather than this project). +are currently filed against vector-im/riot-web rather than this project). Developer Guide =============== @@ -44,15 +44,15 @@ https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst Please follow the Matrix JS/React code style as per: https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst -Whilst the layering separation between matrix-react-sdk and Vector is broken +Whilst the layering separation between matrix-react-sdk and Riot is broken (as of July 2016), code should be committed as follows: * All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components - * Vector-specific components: https://github.com/vector-im/vector-web/tree/master/src/components + * Riot-specific components: https://github.com/vector-im/riot-web/tree/master/src/components * In practice, `matrix-react-sdk` is still evolving so fast that the maintenance - burden of customising and overriding these components for Vector can seriously - impede development. So right now, there should be very few (if any) customisations for Vector. - * CSS for Matrix SDK components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk - * CSS for Vector-specific overrides and components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/vector-web + burden of customising and overriding these components for Riot can seriously + impede development. So right now, there should be very few (if any) customisations for Riot. + * CSS for Matrix SDK components: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/matrix-react-sdk + * CSS for Riot-specific overrides and components: https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/riot-web React components in matrix-react-sdk are come in two different flavours: 'structures' and 'views'. Structures are stateful components which handle the @@ -76,7 +76,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: * The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css). CSS for matrix-react-sdk currently resides in - https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk. + https://github.com/vector-im/riot-web/tree/master/src/skins/vector/css/matrix-react-sdk. * Per-view CSS is optional - it could choose to inherit all its styling from the context of the rest of the app, although this is unusual for any but @@ -129,7 +129,7 @@ from it. Github Issues ============= -All issues should be filed under https://github.com/vector-im/vector-web/issues +All issues should be filed under https://github.com/vector-im/riot-web/issues for now. OUTDATED: To Create Your Own Skin 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 34bb87eb79..dabac0a060 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.7.5", + "version": "0.8.5", "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", @@ -42,13 +43,16 @@ }, "dependencies": { "babel-runtime": "^6.11.6", - "browser-encrypt-attachment": "^0.1.0", + "blueimp-canvas-to-blob": "^3.5.0", + "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.4.0", + "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", @@ -57,22 +61,22 @@ "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", - "react": "^15.2.1", - "react-addons-css-transition-group": "^15.2.1", - "react-dom": "^15.2.1", + "react": "^15.4.0", + "react-addons-css-transition-group": "15.3.2", + "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", @@ -84,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", @@ -99,7 +104,7 @@ "karma-webpack": "^1.7.0", "mocha": "^2.4.5", "phantomjs-prebuilt": "^2.1.7", - "react-addons-test-utils": "^15.0.1", + "react-addons-test-utils": "^15.4.0", "require-json": "0.0.1", "rimraf": "^2.4.3", "sinon": "^1.17.3", 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 897a1a2dc8..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,10 +60,10 @@ 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) { + displayNotification(title: string, msg: string, avatarUrl: string, room: Object) { } /** @@ -73,4 +73,13 @@ export default class BasePlatform { getAppVersion() { throw new Error("getAppVersion not implemented!"); } + + /* + * If it's not expected that capturing the screen will work + * with getUserMedia, return a string explaining why not. + * Otherwise, return null. + */ + screenCaptureErrorString() { + return "Not implemented"; + } } diff --git a/src/CallHandler.js b/src/CallHandler.js index 2f931d8e3f..268a599d8e 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -52,6 +52,7 @@ limitations under the License. */ var MatrixClientPeg = require('./MatrixClientPeg'); +var PlatformPeg = require("./PlatformPeg"); var Modal = require('./Modal'); var sdk = require('./index'); var Matrix = require("matrix-js-sdk"); @@ -158,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) { @@ -187,6 +188,17 @@ function _onAction(payload) { ); } else if (payload.type === 'screensharing') { + const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); + if (screenCapErrorString) { + _setCallState(undefined, newCall.roomId, "ended"); + console.log("Can't capture screen: " + screenCapErrorString); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Unable to capture screen", + description: screenCapErrorString + }); + return; + } newCall.placeScreenSharingCall( payload.remote_element, payload.local_element @@ -280,7 +292,7 @@ function _onAction(payload) { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createDialog(QuestionDialog, { title: "Warning!", - description: "Conference calling in Riot is in development and may not be reliable.", + description: "Conference calling is in development and may not be reliable.", onFinished: confirm=>{ if (confirm) { ConferenceHandler.createNewMatrixCall( diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 28c28e875e..17c8155c1b 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -25,22 +25,87 @@ var Modal = require('./Modal'); var encrypt = require("browser-encrypt-attachment"); -function infoForImageFile(imageFile) { - var deferred = q.defer(); +// Polyfill for Canvas.toBlob API using Canvas.toDataURL +require("blueimp-canvas-to-blob"); + +const MAX_WIDTH = 800; +const MAX_HEIGHT = 600; + + +/** + * Create a thumbnail for a image DOM element. + * The image will be smaller than MAX_WIDTH and MAX_HEIGHT. + * The thumbnail will have the same aspect ratio as the original. + * Draws the element into a canvas using CanvasRenderingContext2D.drawImage + * Then calls Canvas.toBlob to get a blob object for the image data. + * + * Since it needs to calculate the dimensions of the source image and the + * thumbnailed image it returns an info object filled out with information + * about the original image and the thumbnail. + * + * @param {HTMLElement} element The element to thumbnail. + * @param {integer} inputWidth The width of the image in the input element. + * @param {integer} inputHeight the width of the image in the input element. + * @param {String} mimeType The mimeType to save the blob as. + * @return {Promise} A promise that resolves with an object with an info key + * and a thumbnail key. + */ +function createThumbnail(element, inputWidth, inputHeight, mimeType) { + const deferred = q.defer(); + + var targetWidth = inputWidth; + var targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); + canvas.toBlob(function(thumbnail) { + deferred.resolve({ + info: { + thumbnail_info: { + w: targetWidth, + h: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + }, + w: inputWidth, + h: inputHeight, + }, + thumbnail: thumbnail + }); + }, mimeType); + + return deferred.promise; +} + +/** + * Load a file into a newly created image element. + * + * @param {File} file The file to load in an image element. + * @return {Promise} A promise that resolves with the html image element. + */ +function loadImageElement(imageFile) { + const deferred = q.defer(); // Load the file into an html element - var img = document.createElement("img"); + const img = document.createElement("img"); - var reader = new FileReader(); + const reader = new FileReader(); reader.onload = function(e) { img.src = e.target.result; - // Once ready, returns its size + // Once ready, create a thumbnail img.onload = function() { - deferred.resolve({ - w: img.width, - h: img.height - }); + deferred.resolve(img); }; img.onerror = function(e) { deferred.reject(e); @@ -54,22 +119,53 @@ function infoForImageFile(imageFile) { return deferred.promise; } -function infoForVideoFile(videoFile) { - var deferred = q.defer(); +/** + * Read the metadata for an image file and create and upload a thumbnail of the image. + * + * @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with. + * @param {String} roomId The ID of the room the image will be uploaded in. + * @param {File} The image to read and thumbnail. + * @return {Promise} A promise that resolves with the attachment info. + */ +function infoForImageFile(matrixClient, roomId, imageFile) { + var thumbnailType = "image/png"; + if (imageFile.type == "image/jpeg") { + thumbnailType = "image/jpeg"; + } + + var imageInfo; + return loadImageElement(imageFile).then(function(img) { + return createThumbnail(img, img.width, img.height, thumbnailType); + }).then(function(result) { + imageInfo = result.info; + return uploadFile(matrixClient, roomId, result.thumbnail); + }).then(function(result) { + imageInfo.thumbnail_url = result.url; + imageInfo.thumbnail_file = result.file; + return imageInfo; + }); +} + +/** + * Load a file into a newly created video element. + * + * @param {File} file The file to load in an video element. + * @return {Promise} A promise that resolves with the video image element. + */ +function loadVideoElement(videoFile) { + const deferred = q.defer(); // Load the file into an html element - var video = document.createElement("video"); + const video = document.createElement("video"); - var reader = new FileReader(); + const reader = new FileReader(); reader.onload = function(e) { video.src = e.target.result; // Once ready, returns its size - video.onloadedmetadata = function() { - deferred.resolve({ - w: video.videoWidth, - h: video.videoHeight - }); + // Wait until we have enough data to thumbnail the first frame. + video.onloadeddata = function() { + deferred.resolve(video); }; video.onerror = function(e) { deferred.reject(e); @@ -83,6 +179,30 @@ function infoForVideoFile(videoFile) { return deferred.promise; } +/** + * Read the metadata for a video file and create and upload a thumbnail of the video. + * + * @param {MatrixClient} matrixClient A matrixClient to upload the thumbnail with. + * @param {String} roomId The ID of the room the video will be uploaded to. + * @param {File} The video to read and thumbnail. + * @return {Promise} A promise that resolves with the attachment info. + */ +function infoForVideoFile(matrixClient, roomId, videoFile) { + const thumbnailType = "image/jpeg"; + + var videoInfo; + return loadVideoElement(videoFile).then(function(video) { + return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); + }).then(function(result) { + videoInfo = result.info; + return uploadFile(matrixClient, roomId, result.thumbnail); + }).then(function(result) { + videoInfo.thumbnail_url = result.url; + videoInfo.thumbnail_file = result.file; + return videoInfo; + }); +} + /** * Read the file as an ArrayBuffer. * @return {Promise} A promise that resolves with an ArrayBuffer when the file @@ -101,6 +221,52 @@ function readFileAsArrayBuffer(file) { return deferred.promise; } +/** + * Upload the file to the content repository. + * If the room is encrypted then encrypt the file before uploading. + * + * @param {MatrixClient} matrixClient The matrix client to upload the file with. + * @param {String} roomId The ID of the room being uploaded to. + * @param {File} file The file to upload. + * @return {Promise} A promise that resolves with an object. + * If the file is unencrypted then the object will have a "url" key. + * If the file is encrypted then the object will have a "file" key. + */ +function uploadFile(matrixClient, roomId, file) { + if (matrixClient.isRoomEncrypted(roomId)) { + // If the room is encrypted then encrypt the file before uploading it. + // First read the file into memory. + return readFileAsArrayBuffer(file).then(function(data) { + // Then encrypt the file. + return encrypt.encryptAttachment(data); + }).then(function(encryptResult) { + // Record the information needed to decrypt the attachment. + const encryptInfo = encryptResult.info; + // Pass the encrypted data as a Blob to the uploader. + const blob = new Blob([encryptResult.data]); + return matrixClient.uploadContent(blob).then(function(url) { + // If the attachment is encrypted then bundle the URL along + // with the information needed to decrypt the attachment and + // add it under a file key. + encryptInfo.url = url; + if (file.type) { + encryptInfo.mimetype = file.type; + } + return {"file": encryptInfo}; + }); + }); + } else { + 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}; + }); + // XXX: copy over the abort method to the new promise + promise1.abort = basePromise.abort; + return promise1; + } +} + class ContentMessages { constructor() { @@ -109,7 +275,7 @@ class ContentMessages { } sendContentToRoom(file, roomId, matrixClient) { - var content = { + const content = { body: file.name, info: { size: file.size, @@ -121,13 +287,14 @@ class ContentMessages { content.info.mimetype = file.type; } - var def = q.defer(); + const def = q.defer(); if (file.type.indexOf('image/') == 0) { content.msgtype = 'm.image'; - infoForImageFile(file).then(imageInfo=>{ + infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{ extend(content.info, imageInfo); def.resolve(); }, error=>{ + console.error(error); content.msgtype = 'm.file'; def.resolve(); }); @@ -136,7 +303,7 @@ class ContentMessages { def.resolve(); } else if (file.type.indexOf('video/') == 0) { content.msgtype = 'm.video'; - infoForVideoFile(file).then(videoInfo=>{ + infoForVideoFile(matrixClient, roomId, file).then(videoInfo=>{ extend(content.info, videoInfo); def.resolve(); }, error=>{ @@ -148,36 +315,27 @@ class ContentMessages { def.resolve(); } - var upload = { + const upload = { fileName: file.name, roomId: roomId, total: 0, - loaded: 0 + loaded: 0, }; this.inprogress.push(upload); dis.dispatch({action: 'upload_started'}); - var encryptInfo = null; var error; - var self = this; return def.promise.then(function() { - if (matrixClient.isRoomEncrypted(roomId)) { - // If the room is encrypted then encrypt the file before uploading it. - // First read the file into memory. - upload.promise = readFileAsArrayBuffer(file).then(function(data) { - // Then encrypt the file. - return encrypt.encryptAttachment(data); - }).then(function(encryptResult) { - // Record the information needed to decrypt the attachment. - encryptInfo = encryptResult.info; - // Pass the encrypted data as a Blob to the uploader. - var blob = new Blob([encryptResult.data]); - return matrixClient.uploadContent(blob); - }); - } else { - upload.promise = matrixClient.uploadContent(file); - } - return upload.promise; + // XXX: upload.promise must be the promise that + // is returned by uploadFile as it has an abort() + // method hacked onto it. + upload.promise = uploadFile( + matrixClient, roomId, file + ); + return upload.promise.then(function(result) { + content.file = result.file; + content.url = result.url; + }); }).progress(function(ev) { if (ev) { upload.total = ev.total; @@ -185,19 +343,6 @@ class ContentMessages { dis.dispatch({action: 'upload_progress', upload: upload}); } }).then(function(url) { - if (encryptInfo === null) { - // If the attachment isn't encrypted then include the URL directly. - content.url = url; - } else { - // If the attachment is encrypted then bundle the URL along - // with the information needed to decrypt the attachment and - // add it under a file key. - encryptInfo.url = url; - if (file.type) { - encryptInfo.mimetype = file.type; - } - content.file = encryptInfo; - } return matrixClient.sendMessage(roomId, content); }, function(err) { error = err; @@ -212,12 +357,12 @@ class ContentMessages { description: desc }); } - }).finally(function() { - var inprogressKeys = Object.keys(self.inprogress); - for (var i = 0; i < self.inprogress.length; ++i) { + }).finally(() => { + const inprogressKeys = Object.keys(this.inprogress); + for (var i = 0; i < this.inprogress.length; ++i) { var k = inprogressKeys[i]; - if (self.inprogress[k].promise === upload.promise) { - self.inprogress.splice(k, 1); + if (this.inprogress[k].promise === upload.promise) { + this.inprogress.splice(k, 1); break; } } @@ -235,7 +380,7 @@ class ContentMessages { } cancelUpload(promise) { - var inprogressKeys = Object.keys(this.inprogress); + const inprogressKeys = Object.keys(this.inprogress); var upload; for (var i = 0; i < this.inprogress.length; ++i) { var k = inprogressKeys[i]; diff --git a/src/DateUtils.js b/src/DateUtils.js index 2b51c5903f..07bab4ae7b 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -48,5 +48,5 @@ module.exports = { //return pad(date.getHours()) + ':' + pad(date.getMinutes()); return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2); } -} +}; diff --git a/src/Entities.js b/src/Entities.js index ac3c976797..7c3909f36f 100644 --- a/src/Entities.js +++ b/src/Entities.js @@ -136,6 +136,6 @@ module.exports = { fromUsers: function(users, showInviteButton, inviteFn) { return users.map(function(u) { return new UserEntity(u, showInviteButton, inviteFn); - }) + }); } }; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index fc1630b6fb..c7b13bc071 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -91,16 +91,16 @@ var sanitizeHtmlParams = { ], allowedAttributes: { // custom ones first: - font: [ 'color' ], // custom to matrix - a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix + font: ['color'], // custom to matrix + a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix // We don't currently allow img itself by default, but this // would make sense if we did - img: [ 'src' ], + img: ['src'], }, // Lots of these won't come up by default because we don't allow them - selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ], + selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit - allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], + allowedSchemes: ['http', 'https', 'ftp', 'mailto'], // DO NOT USE. sanitize-html allows all URL starting with '//' // so this will always allow links to whatever scheme the diff --git a/src/ImageUtils.js b/src/ImageUtils.js index fdb12c7608..3744241874 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.js @@ -53,5 +53,5 @@ module.exports = { return Math.floor(heightMulti * fullHeight); } }, -} +}; diff --git a/src/Invite.js b/src/Invite.js index 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 62fbe5f929..493bbf12aa 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -18,7 +18,7 @@ import q from 'q'; import Matrix from 'matrix-js-sdk'; import MatrixClientPeg from './MatrixClientPeg'; -import Notifier from './Notifier' +import Notifier from './Notifier'; import UserActivity from './UserActivity'; import Presence from './Presence'; import dis from './dispatcher'; @@ -140,7 +140,7 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) { homeserverUrl: queryParams.homeserver, identityServerUrl: queryParams.identityServer, guest: false, - }) + }); }, (err) => { console.error("Failed to log in with login token: " + err + " " + err.data); @@ -356,7 +356,7 @@ export function stopMatrixClient() { Notifier.stop(); UserActivity.stop(); Presence.stop(); - DMRoomMap.shared().stop(); + if (DMRoomMap.shared()) DMRoomMap.shared().stop(); var cli = MatrixClientPeg.get(); if (cli) { cli.stopClient(); diff --git a/src/Markdown.js b/src/Markdown.js index a7b267b110..2f278183a3 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -14,20 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import marked from 'marked'; - -// 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
) -}); +import commonmark from 'commonmark'; /** * Class that wraps marked, adding the ability to see whether @@ -36,16 +23,9 @@ const marked_options = Object.assign({}, marked.defaults, { */ export default class Markdown { constructor(input) { - const lexer = new marked.Lexer(marked_options); - this.tokens = lexer.lex(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); + this.input = input; + this.parser = new commonmark.Parser(); + this.renderer = new commonmark.HtmlRenderer({safe: false}); } isPlainText() { @@ -64,65 +44,81 @@ export default class Markdown { is_plain = false; } - const dummy_renderer = {}; - for (const k of Object.keys(marked.Renderer.prototype)) { + const dummy_renderer = new commonmark.HtmlRenderer(); + for (const k of Object.keys(commonmark.HtmlRenderer.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;} + dummy_renderer.text = function(t) { return t; }; + dummy_renderer.softbreak = 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; - } - } - - const dummy_options = Object.assign({}, marked_options, { - renderer: dummy_renderer, - }); - const dummy_parser = new marked.Parser(dummy_options); - dummy_parser.parse(this._copyTokens()); + const dummy_parser = new commonmark.Parser(); + dummy_renderer.render(dummy_parser.parse(this.input)); return is_plain; } 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 real_paragraph = this.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 + this.renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the // bare text: it's a single line of text and so should be - // 'inline', rather than 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; + var par = node; + while (par.parent) { + par = par.parent; } - return '

' + text + '

'; - } + if (par.firstChild != par.lastChild) { + real_paragraph.call(this, node, entering); + } + }; - const real_options = Object.assign({}, marked_options, { - renderer: real_renderer, - }); - const real_parser = new marked.Parser(real_options); - return real_parser.parse(this._copyTokens()); + var parsed = this.parser.parse(this.input); + var rendered = this.renderer.render(parsed); + + this.renderer.paragraph = real_paragraph; + + return rendered; + } + + toPlaintext() { + const real_paragraph = this.renderer.paragraph; + + // The default `out` function only sends the input through an XML + // escaping function, which causes messages to be entity encoded, + // which we don't want in this case. + this.renderer.out = function(s) { + // The `lit` function adds a string literal to the output buffer. + this.lit(s); + }; + + this.renderer.paragraph = function(node, entering) { + // If there is only one top level node, just return the + // bare text: it's a single line of text and so should be + // 'inline', rather than unnecessarily wrapped in its own + // p tag. If, however, we have multiple nodes, each gets + // its own p tag to keep them as separate paragraphs. + var par = node; + while (par.parent) { + node = par; + par = par.parent; + } + if (node != par.lastChild) { + if (!entering) { + this.lit('\n\n'); + } + } + }; + + var parsed = this.parser.parse(this.input); + var rendered = this.renderer.render(parsed); + + this.renderer.paragraph = real_paragraph; + + return rendered; } } diff --git a/src/Modal.js b/src/Modal.js index 44072b9278..89e8b1361c 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,44 +19,174 @@ 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; + this.props.loader((e) => { + if (this._unmounted) { + return; + } + this.setState({component: e}); + }); + }, + + componentWillUnmount: function() { + this._unmounted = true; + }, + + render: function() { + const {loader, ...otherProps} = this.props; + + if (this.state.component) { + const Component = this.state.component; + return ; + } else { + // show a spinner until the component is loaded. + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + }, +}); + +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 b9260a046d..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 @@ -73,7 +73,7 @@ var Notifier = { ev.sender, 40, 40, 'crop' ) : null; - const notif = plaf.displayNotification(title, msg, avatarUrl); + const notif = plaf.displayNotification(title, msg, avatarUrl, room); // if displayNotification returns non-null, the platform supports // clearing notifications later, so keep track of this. @@ -88,7 +88,7 @@ var Notifier = { if (e) { e.load(); e.play(); - }; + } }, start: function() { diff --git a/src/ObjectUtils.js b/src/ObjectUtils.js index 07a16df501..5fac588a4f 100644 --- a/src/ObjectUtils.js +++ b/src/ObjectUtils.js @@ -64,7 +64,7 @@ module.exports.getKeyValueArrayDiffs = function(before, after) { } else if (itemDelta[item] === -1) { results.push({ place: "del", key: muxedKey, val: item }); } else { - // itemDelta of 0 means it was unchanged between before/after + // itemDelta of 0 means it was unchanged between before/after } }); break; diff --git a/src/Presence.js b/src/Presence.js index 4152d7a487..c45d571217 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -111,7 +111,7 @@ class Presence { this.timer = setTimeout(function() { self._onUnavailableTimerFire(); }, UNAVAILABLE_TIME_MS); - } + } } module.exports = new Presence(); diff --git a/src/RichText.js b/src/RichText.js index 5fe920fe50..b1793d0ddf 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -12,7 +12,7 @@ import { SelectionState, Entity, } from 'draft-js'; -import * as sdk from './index'; +import * as sdk from './index'; import * as emojione from 'emojione'; import {stateToHTML} from 'draft-js-export-html'; import {SelectionRange} from "./autocomplete/Autocompleter"; @@ -109,7 +109,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { return {avatar}{props.children}; } }; - + let roomDecorator = { strategy: (contentBlock, callback) => { findWithRegex(ROOM_REGEX, contentBlock, callback); diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index 09f178dd3f..7a43c1891e 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -26,7 +26,7 @@ function tsOfNewestEvent(room) { } function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a,b) { + return roomList.sort(function(a, b) { return tsOfNewestEvent(b) - tsOfNewestEvent(a); }); } diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index d0cdd6ead7..7cb7d4b9de 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -146,7 +146,7 @@ function isRuleForRoom(roomId, rule) { } const cond = rule.conditions[0]; if ( - cond.kind == 'event_match' && + cond.kind == 'event_match' && cond.key == 'room_id' && cond.pattern == roomId ) { diff --git a/src/Rooms.js b/src/Rooms.js index cf62f2dda0..fbcc843ad2 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -37,7 +37,7 @@ export function getOnlyOtherMember(room, me) { if (joinedMembers.length === 2) { return joinedMembers.filter(function(m) { - return m.userId !== me.userId + return m.userId !== me.userId; })[0]; } diff --git a/src/RtsClient.js b/src/RtsClient.js new file mode 100644 index 0000000000..ae62fb8b22 --- /dev/null +++ b/src/RtsClient.js @@ -0,0 +1,80 @@ +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, + }, + } + ); + } +} diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index db2baa433b..dbb7e405df 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -292,12 +292,15 @@ const onMessage = function(event) { event.origin = event.originalEvent.origin; } - // check it is from the integrations UI URL (remove trailing spaces) + // Check that the integrations UI URL starts with the origin of the event + // This means the URL could contain a path (like /develop) and still be used + // to validate event origins, which do not specify paths. + // (See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) + // + // All strings start with the empty string, so for sanity return if the length + // of the event origin is 0. let url = SdkConfig.get().integrations_ui_url; - if (url.endsWith("/")) { - url = url.substr(0, url.length - 1); - } - if (url !== event.origin) { + if (event.origin.length === 0 || !url.startsWith(event.origin)) { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } @@ -368,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 a76919f34e..d3643bd749 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -191,7 +191,7 @@ class Register extends Signup { } } if (poll_for_success) { - return q.delay(5000).then(function() { + return q.delay(2000).then(function() { return self._tryRegister(client, authDict, poll_for_success); }); } else { @@ -203,7 +203,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 283b11afef..6bdc331566 100644 --- a/src/SignupStages.js +++ b/src/SignupStages.js @@ -52,7 +52,13 @@ DummyStage.TYPE = "m.login.dummy"; class RecaptchaStage extends Stage { constructor(matrixClient, signupInstance) { super(RecaptchaStage.TYPE, matrixClient, signupInstance); - this.defer = q.defer(); // resolved with the captcha response + this.authDict = { + auth: { + type: 'm.login.recaptcha', + // we'll add in the response param if we get one from the local user. + }, + poll_for_success: true, + }; } // called when the recaptcha has been completed. @@ -60,16 +66,15 @@ class RecaptchaStage extends Stage { if (!data || !data.response) { return; } - this.defer.resolve({ - auth: { - type: 'm.login.recaptcha', - response: data.response, - } - }); + this.authDict.auth.response = data.response; } complete() { - return this.defer.promise; + // we return the authDict with no response, telling Signup to keep polling + // the server in case the captcha is filled in on another window (e.g. by + // following a nextlink from an email signup). If the user completes the + // captcha locally, then we return at the next poll. + return q(this.authDict); } } RecaptchaStage.TYPE = "m.login.recaptcha"; 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 65441c9381..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; @@ -227,8 +227,20 @@ class TabComplete { // pressing any key at all (except tab) restarts the automatic tab-complete timer if (this.opts.autoEnterTabComplete) { + const cachedText = ev.target.value; clearTimeout(this.enterTabCompleteTimerId); this.enterTabCompleteTimerId = setTimeout(() => { + if (this.completing) { + // If you highlight text and CTRL+X it, tab-completing will not be reset. + // This check makes sure that if something like a cut operation has been + // done, that we correctly refresh the tab-complete list. Normal backspace + // operations get caught by the stopTabCompleting() section above, but + // because the CTRL key is held, this does not execute for CTRL+X. + if (cachedText !== this.textArea.value) { + this.stopTabCompleting(); + } + } + if (!this.completing) { this.handleTabPress(true, false); } @@ -242,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(); @@ -374,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 336fb90fa2..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,10 +149,28 @@ 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. +const tintables = []; + module.exports = { + /** + * Register a callback to fire when the tint changes. + * This is used to rewrite the tintable SVGs with the new tint. + * + * It's not possible to unregister a tintable callback. So this can only be + * used to register a static callback. If a set of tintables will change + * over time then the best bet is to register a single callback for the + * entire set. + * + * @param {Function} tintable Function to call when the tint changes. + */ + registerTintable : function(tintable) { + tintables.push(tintable); + }, + tint: function(primaryColor, secondaryColor, tertiaryColor) { if (!cached) { @@ -167,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; @@ -176,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]; @@ -192,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"); @@ -201,12 +220,22 @@ module.exports = { // tell all the SVGs to go fix themselves up // we don't do this as a dispatch otherwise it will visually lag - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - if (TintableSvg.mounts) { - Object.keys(TintableSvg.mounts).forEach((id) => { - TintableSvg.mounts[id].tint(); - }); + tintables.forEach(function(tintable) { + tintable(); + }); + }, + + 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 @@ -265,5 +294,5 @@ module.exports = { svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]); } if (DEBUG) console.log("applySvgFixups end"); - }, + } }; diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 845e81de36..e5dba62ee7 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -30,16 +30,6 @@ module.exports = { id: 'rich_text_editor', default: false, }, - { - name: 'End-to-End Encryption', - id: 'e2e_encryption', - default: true, - }, - { - name: 'Integration Management', - id: 'integration_management', - default: true, - }, ], loadProfileInfo: function() { diff --git a/src/Velociraptor.js b/src/Velociraptor.js index d9b6b3d5dc..006dbcb0ac 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -76,7 +76,7 @@ module.exports = React.createClass({ var startStyles = self.props.startStyles; if (startStyles.length > 0) { - var startStyle = startStyles[0] + var startStyle = startStyles[0]; newProps.style = startStyle; // console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); } @@ -105,7 +105,7 @@ module.exports = React.createClass({ ) { var startStyles = this.props.startStyles; var transitionOpts = this.props.enterTransitionOpts; - var domNode = ReactDom.findDOMNode(node); + const domNode = ReactDom.findDOMNode(node); // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. for (var i = 1; i < startStyles.length; ++i) { @@ -145,7 +145,7 @@ module.exports = React.createClass({ // and the FAQ entry, "Preventing memory leaks when // creating/destroying large numbers of elements" // (https://github.com/julianshapiro/velocity/issues/47) - var domNode = ReactDom.findDOMNode(this.nodes[k]); + const domNode = ReactDom.findDOMNode(this.nodes[k]); Velocity.Utilities.removeData(domNode); } this.nodes[k] = node; diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js index 168b0b14af..3ad7d207a9 100644 --- a/src/VelocityBounce.js +++ b/src/VelocityBounce.js @@ -6,10 +6,12 @@ function bounce( p ) { var pow2, bounce = 4; - while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {} + while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) { + // just sets pow2 + } return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 ); } Velocity.Easings.easeOutBounce = function(p) { return 1 - bounce(1 - p); -} +}; diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index 4fb5399027..96e76d618b 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -32,18 +32,25 @@ module.exports = { return whoIsTyping; }, - whoIsTypingString: function(room) { - var whoIsTyping = this.usersTypingApartFromMe(room); + whoIsTypingString: function(room, limit) { + const whoIsTyping = this.usersTypingApartFromMe(room); + const othersCount = limit === undefined ? + 0 : Math.max(whoIsTyping.length - limit, 0); if (whoIsTyping.length == 0) { - return null; + return ''; } else if (whoIsTyping.length == 1) { return whoIsTyping[0].name + ' is typing'; + } + const names = whoIsTyping.map(function(m) { + return m.name; + }); + if (othersCount) { + const other = ' other' + (othersCount > 1 ? 's' : ''); + return names.slice(0, limit).join(', ') + ' and ' + + othersCount + other + ' are typing'; } else { - var names = whoIsTyping.map(function(m) { - return m.name; - }); - var lastPerson = names.shift(); + const lastPerson = names.pop(); return names.join(', ') + ' and ' + lastPerson + ' are typing'; } } -} +}; diff --git a/src/components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js similarity index 99% rename from src/components/views/dialogs/EncryptedEventDialog.js rename to src/async-components/views/dialogs/EncryptedEventDialog.js index c86b1d20f8..ba706e0aa5 100644 --- a/src/components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -83,7 +83,7 @@ module.exports = React.createClass({ var verificationStatus = (NOT verified); if (device.isBlocked()) { - verificationStatus = (Blocked); + verificationStatus = (Blacklisted); } else if (device.isVerified()) { verificationStatus = "verified"; } 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..c50ee0dfc8 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,8 @@ 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$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..5166619d48 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -35,7 +35,7 @@ var FilePanel = React.createClass({ getInitialState: function() { return { timelineSet: null, - } + }; }, componentWillMount: function() { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 5ad7c590fa..fb553535aa 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 @@ -161,8 +162,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: @@ -171,24 +172,25 @@ 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 = + />; + if (!this.props.collapse_rhs) right_panel = ; break; case PageTypes.HomePage: @@ -201,7 +203,7 @@ export default React.createClass({ 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 9a3ff8f95c..f3fc5bf260 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -66,15 +66,28 @@ module.exports = React.createClass({ defaultDeviceDisplayName: React.PropTypes.string, }, + childContextTypes: { + appConfig: React.PropTypes.object, + }, + AuxPanel: { RoomSettings: "room_settings", }, + getChildContext: function() { + return { + appConfig: this.props.config, + }; + }, + getInitialState: function() { var s = { loading: true, screen: undefined, + // What the LoggedInView would be showing if visible + page_type: null, + // If we are viewing a room by alias, this contains the alias currentRoomAlias: null, @@ -235,8 +248,6 @@ module.exports = React.createClass({ setStateForNewScreen: function(state) { const newState = { screen: undefined, - currentRoomAlias: null, - currentRoomId: null, viewUserId: null, logged_in: false, ready: false, @@ -449,6 +460,9 @@ module.exports = React.createClass({ middleOpacity: payload.middleOpacity, }); break; + case 'set_theme': + this._onSetTheme(payload.value); + break; case 'on_logged_in': this._onLoggedIn(); break; @@ -579,6 +593,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 */ @@ -601,6 +659,9 @@ module.exports = React.createClass({ ready: false, collapse_lhs: false, collapse_rhs: false, + currentRoomAlias: null, + currentRoomId: null, + page_type: PageTypes.RoomDirectory, }); }, @@ -687,6 +748,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) { @@ -983,7 +1054,7 @@ module.exports = React.createClass({ {...this.props} {...this.state} /> - ) + ); } else if (this.state.logged_in) { // we think we are logged in, but are still waiting for the /sync to complete var Spinner = sdk.getComponent('elements.Spinner'); @@ -1002,11 +1073,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} @@ -1025,6 +1098,7 @@ module.exports = React.createClass({ customHsUrl={this.getCurrentHsUrl()} customIsUrl={this.getCurrentIsUrl()} onComplete={this.onLoginClick} + onRegisterClick={this.onRegisterClick} onLoginClick={this.onLoginClick} /> ); } else { diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index ef4d10e66b..dcebe38fa4 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -19,7 +19,9 @@ 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; /* (almost) stateless UI component which builds the event tiles in the room timeline. */ @@ -229,6 +231,7 @@ module.exports = React.createClass({ _getEventTiles: function() { var EventTile = sdk.getComponent('rooms.EventTile'); + var DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); this.eventNodes = {}; @@ -278,8 +281,7 @@ module.exports = React.createClass({ var isMembershipChange = (e) => e.getType() === 'm.room.member' - && ['join', 'leave'].indexOf(e.event.content.membership) !== -1 - && (!e.event.prev_content || e.event.content.membership !== e.event.prev_content.membership); + && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership); for (i = 0; i < this.props.events.length; i++) { var mxEv = this.props.events[i]; @@ -292,37 +294,63 @@ module.exports = React.createClass({ var last = (i == lastShownEventIndex); - // Wrap consecutive member events in a ListSummary - if (isMembershipChange(mxEv)) { - let summarisedEvents = [mxEv]; - i++; - for (;i < this.props.events.length; i++) { - let collapsedMxEv = this.props.events[i]; + // 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 + // instead will allow new props to be provided. In turn, the shouldComponentUpdate + // method on MELS can be used to prevent unnecessary renderings. + // + // Whilst back-paginating with a MELS at the top of the panel, prevEvent will be null, + // so use the key "membereventlistsummary-initial". Otherwise, use the ID of the first + // membership event, which will not change during forward pagination. + const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial"); - if (!isMembershipChange(collapsedMxEv)) { - i--; + if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { + let dateSeparator =
  • ; + ret.push(dateSeparator); + } + + let summarisedEvents = [mxEv]; + 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; } summarisedEvents.push(collapsedMxEv); } - // At this point, i = this.props.events.length OR i = the index of the last - // MembershipChange in a sequence of MembershipChanges + // At this point, i = the index of the last event in the summary sequence let eventTiles = summarisedEvents.map( (e) => { - let ret = this._getTilesForEvent(prevEvent, e); + // In order to prevent DateSeparators from appearing in the expanded form + // of MemberEventListSummary, render each member event as if the previous + // one was itself. This way, the timestamp of the previous event === the + // timestamp of the current event, and no DateSeperator is inserted. + let ret = this._getTilesForEvent(e, e); prevEvent = e; return ret; } - ).reduce((a,b) => a.concat(b)); + ).reduce((a, b) => a.concat(b)); if (eventTiles.length === 0) { eventTiles = null; } ret.push( - - {eventTiles} + + {eventTiles} ); continue; @@ -405,12 +433,14 @@ module.exports = React.createClass({ // local echoes have a fake date, which could even be yesterday. Treat them // as 'today' for the date separators. var ts1 = mxEv.getTs(); + var eventDate = mxEv.getDate(); if (mxEv.status) { - ts1 = new Date(); + eventDate = new Date(); + ts1 = eventDate.getTime(); } // do we need a date separator since the last event? - if (this._wantsDateSeparator(prevEvent, ts1)) { + if (this._wantsDateSeparator(prevEvent, eventDate)) { var dateSeparator =
  • ; ret.push(dateSeparator); continuation = false; @@ -447,38 +477,48 @@ module.exports = React.createClass({ return ret; }, - _wantsDateSeparator: function(prevEvent, nextEventTs) { + _wantsDateSeparator: function(prevEvent, nextEventDate) { if (prevEvent == null) { // first event in the panel: depends if we could back-paginate from // here. return !this.props.suppressFirstDateSeparator; } - - return (new Date(prevEvent.getTs()).toDateString() - !== new Date(nextEventTs).toDateString()); - }, - - // get a list of the userids whose read receipts should - // be shown next to this event - _getReadReceiptsForEvent: function(event) { - var myUserId = MatrixClientPeg.get().credentials.userId; - - // get list of read receipts, sorted most recent first - var room = MatrixClientPeg.get().getRoom(event.getRoomId()); - if (!room) { - // huh. - return null; + // Return early for events that are > 24h apart + if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) { + return true; } - return room.getReceiptsForEvent(event).filter(function(r) { - return r.type === "m.read" && r.userId != myUserId; - }).sort(function(r1, r2) { - return r2.data.ts - r1.data.ts; - }).map(function(r) { - return room.getMember(r.userId); - }).filter(function(m) { - // check that the user is a known room member - return m; + // Compare weekdays + return prevEvent.getDate().getDay() !== nextEventDate.getDay(); + }, + + // get a list of read receipts that should be shown next to this event + // Receipts are objects which have a 'roomMember' and 'ts'. + _getReadReceiptsForEvent: function(event) { + const myUserId = MatrixClientPeg.get().credentials.userId; + + // get list of read receipts, sorted most recent first + const room = MatrixClientPeg.get().getRoom(event.getRoomId()); + if (!room) { + return null; + } + let receipts = []; + room.getReceiptsForEvent(event).forEach((r) => { + if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { + return; // ignore non-read receipts and receipts from self. + } + let member = room.getMember(r.userId); + if (!member) { + return; // ignore unknown user IDs + } + receipts.push({ + roomMember: member, + ts: r.data ? r.data.ts : 0, + }); + }); + + return receipts.sort((r1, r2) => { + return r2.ts - r1.ts; }); }, @@ -564,6 +604,7 @@ module.exports = React.createClass({ onScroll={ this.props.onScroll } onResize={ this.onResize } onFillRequest={ this.props.onFillRequest } + onUnfillRequest={ this.props.onUnfillRequest } style={ style } stickyBottom={ this.props.stickyBottom }> {topSpinner} diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index c6f2d6500b..3ba73bb181 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', @@ -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,28 @@ module.exports = React.createClass({ // status bar. This is used to trigger a re-layout in the parent // component. onResize: React.PropTypes.func, + + // callback for when the status bar can be hidden from view, as it is + // not displaying anything + onHidden: React.PropTypes.func, + // callback for when the status bar is displaying something and should + // be visible + onVisible: React.PropTypes.func, + }, + + getDefaultProps: function() { + return { + whoIsTypingLimit: 2, + }; }, getInitialState: function() { return { syncState: MatrixClientPeg.get().getSyncState(), - whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + whoisTypingString: WhoIsTyping.whoIsTypingString( + this.props.room, + this.props.whoIsTypingLimit + ), }; }, @@ -78,6 +104,18 @@ module.exports = React.createClass({ if(this.props.onResize && this._checkForResize(prevProps, prevState)) { this.props.onResize(); } + + const size = this._getSize(this.state, this.props); + if (size > 0) { + this.props.onVisible(); + } else { + if (this.hideDebouncer) { + clearTimeout(this.hideDebouncer); + } + this.hideDebouncer = setTimeout(() => { + this.props.onHidden(); + }, HIDE_DEBOUNCE_MS); + } }, componentWillUnmount: function() { @@ -100,39 +138,35 @@ module.exports = React.createClass({ onRoomMemberTyping: function(ev, member) { this.setState({ - whoisTypingString: WhoIsTyping.whoIsTypingString(this.props.room), + whoisTypingString: WhoIsTyping.whoIsTypingString( + this.props.room, + this.props.whoIsTypingLimit + ), }); }, + // We don't need the actual height - just whether it is likely to have + // changed - so we use '0' to indicate normal size, and other values to + // indicate other sizes. + _getSize: function(state, props) { + if (state.syncState === "ERROR" || + state.whoisTypingString || + props.numUnreadMessages || + !props.atEndOfLiveTimeline || + props.hasActiveCall) { + return STATUS_BAR_EXPANDED; + } else if (props.tabCompleteEntries) { + return STATUS_BAR_HIDDEN; + } else if (props.hasUnsentMessages) { + return STATUS_BAR_EXPANDED_LARGE; + } + return STATUS_BAR_HIDDEN; + }, + // determine if we need to call onResize _checkForResize: function(prevProps, prevState) { - // figure out the old height and the new height of the status bar. We - // don't need the actual height - just whether it is likely to have - // changed - so we use '0' to indicate normal size, and other values to - // indicate other sizes. - var oldSize, newSize; - - if (prevState.syncState === "ERROR") { - oldSize = 1; - } else if (prevProps.tabCompleteEntries) { - oldSize = 0; - } else if (prevProps.hasUnsentMessages) { - oldSize = 2; - } else { - oldSize = 0; - } - - if (this.state.syncState === "ERROR") { - newSize = 1; - } else if (this.props.tabCompleteEntries) { - newSize = 0; - } else if (this.props.hasUnsentMessages) { - newSize = 2; - } else { - newSize = 0; - } - - return newSize != oldSize; + // figure out the old height and the new height of the status bar. + return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state); }, // return suitable content for the image on the left of the status bar. @@ -173,10 +207,8 @@ module.exports = React.createClass({ if (wantPlaceholder) { return ( -
    - . - . - . +
    + {this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit)}
    ); } @@ -184,6 +216,36 @@ module.exports = React.createClass({ return null; }, + _renderTypingIndicatorAvatars: function(limit) { + let users = WhoIsTyping.usersTypingApartFromMe(this.props.room); + + let othersCount = Math.max(users.length - limit, 0); + users = users.slice(0, limit); + + let avatars = users.map((u, index) => { + let showInitial = othersCount === 0 && index === users.length - 1; + return ( + + ); + }); + + if (othersCount > 0) { + avatars.push( + + +{othersCount} + + ); + } + + return avatars; + }, // return suitable content for the main (text) part of the status bar. _getContent: function() { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 2d71943bce..38b3346e29 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -48,7 +48,7 @@ if (DEBUG) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { - var debuglog = function () {}; + var debuglog = function() {}; } module.exports = React.createClass({ @@ -146,7 +146,9 @@ module.exports = React.createClass({ showTopUnreadMessagesBar: false, auxPanelMaxHeight: undefined, - } + + statusBarVisible: false, + }; }, componentWillMount: function() { @@ -674,8 +676,9 @@ module.exports = React.createClass({ }, onSearchResultsFillRequest: function(backwards) { - if (!backwards) + if (!backwards) { return q(false); + } if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); @@ -719,15 +722,11 @@ module.exports = React.createClass({ if (!result.displayname) { var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog'); var dialog_defer = q.defer(); - var dialog_ref; Modal.createDialog(SetDisplayNameDialog, { currentDisplayName: result.displayname, - ref: (r) => { - dialog_ref = r; - }, - onFinished: (submitted) => { + onFinished: (submitted, newDisplayName) => { if (submitted) { - cli.setDisplayName(dialog_ref.getValue()).done(() => { + cli.setDisplayName(newDisplayName).done(() => { dialog_defer.resolve(); }); } @@ -758,7 +757,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; @@ -810,11 +809,6 @@ module.exports = React.createClass({ } else { var msg = error.message ? error.message : JSON.stringify(error); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - if (msg === "No known servers") { - // minging kludge until https://matrix.org/jira/browse/SYN-678 is fixed - // 'Error when trying to join an empty room should be more explicit' - msg = "It is not currently possible to re-join an empty room."; - } Modal.createDialog(ErrorDialog, { title: "Failed to join room", description: msg @@ -967,7 +961,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, @@ -1030,7 +1024,7 @@ module.exports = React.createClass({ if (scrollPanel) { scrollPanel.checkScroll(); } - } + }; var lastRoomId; @@ -1095,7 +1089,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) { @@ -1104,7 +1098,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 } @@ -1188,7 +1182,7 @@ module.exports = React.createClass({ this.setState({ searching: true }); }, - onCancelSearchClick: function () { + onCancelSearchClick: function() { this.setState({ searching: false, searchResults: null, @@ -1213,8 +1207,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(); @@ -1336,6 +1331,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) { @@ -1500,13 +1509,14 @@ 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={2} + />; } var aux = null; @@ -1574,7 +1587,7 @@ module.exports = React.createClass({ messageComposer = + callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>; } // TODO: Why aren't we storing the term/scope/count in this format @@ -1602,14 +1615,14 @@ module.exports = React.createClass({ {call.isLocalVideoMuted() -
    +
    ; } voiceMuteButton =
    {call.isMicrophoneMuted() -
    + ; // wrap the existing status bar into a 'callStatusBar' which adds more knobs. statusBar = @@ -1619,7 +1632,7 @@ module.exports = React.createClass({ { zoomButton } { statusBar } - + ; } // if we have search results, we keep the messagepanel (so that it preserves its @@ -1672,6 +1685,10 @@ module.exports = React.createClass({ ); } + let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable"; + if (isStatusAreaExpanded) { + statusBarAreaClass += " mx_RoomView_statusArea_expanded"; + } return (
    @@ -1694,7 +1711,7 @@ module.exports = React.createClass({ { topUnreadMessagesBar } { messagePanel } { searchResultsPanel } -
    +
    { statusBar } diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 6970cd190c..c6bcdc45cd 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -23,11 +23,18 @@ var KeyCode = require('../../KeyCode'); var DEBUG_SCROLL = false; // var DEBUG_SCROLL = true; +// The amount of extra scroll distance to allow prior to unfilling. +// See _getExcessHeight. +const UNPAGINATION_PADDING = 1500; +// The number of milliseconds to debounce calls to onUnfillRequest, to prevent +// many scroll events causing many unfilling requests. +const UNFILL_REQUEST_DEBOUNCE_MS = 200; + 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. @@ -101,6 +108,17 @@ module.exports = React.createClass({ */ onFillRequest: React.PropTypes.func, + /* onUnfillRequest(backwards): a callback which is called on scroll when + * there are children elements that are far out of view and could be removed + * without causing pagination to occur. + * + * This function should accept a boolean, which is true to indicate the back/top + * of the panel and false otherwise, and a scroll token, which refers to the + * first element to remove if removing from the front/bottom, and last element + * to remove if removing from the back/top. + */ + onUnfillRequest: React.PropTypes.func, + /* onScroll: a callback which is called whenever any scroll happens. */ onScroll: React.PropTypes.func, @@ -124,6 +142,7 @@ module.exports = React.createClass({ stickyBottom: true, startAtBottom: true, onFillRequest: function(backwards) { return q(false); }, + onUnfillRequest: function(backwards, scrollToken) {}, onScroll: function() {}, }; }, @@ -226,6 +245,46 @@ module.exports = React.createClass({ return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; }, + // returns the vertical height in the given direction that can be removed from + // the content box (which has a height of scrollHeight, see checkFillState) without + // pagination occuring. + // + // padding* = UNPAGINATION_PADDING + // + // ### Region determined as excess. + // + // .---------. - - + // |#########| | | + // |#########| - | scrollTop | + // | | | padding* | | + // | | | | | + // .-+---------+-. - - | | + // : | | : | | | + // : | | : | clientHeight | | + // : | | : | | | + // .-+---------+-. - - | + // | | | | | | + // | | | | | clientHeight | scrollHeight + // | | | | | | + // `-+---------+-' - | + // : | | : | | + // : | | : | clientHeight | + // : | | : | | + // `-+---------+-' - - | + // | | | padding* | + // | | | | + // |#########| - | + // |#########| | + // `---------' - + _getExcessHeight: function(backwards) { + var sn = this._getScrollNode(); + if (backwards) { + return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING; + } else { + return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; + } + }, + // check the scroll state and send out backfill requests if necessary. checkFillState: function() { if (this.unmounted) { @@ -268,6 +327,55 @@ module.exports = React.createClass({ } }, + // check if unfilling is possible and send an unfill request if necessary + _checkUnfillState: function(backwards) { + let excessHeight = this._getExcessHeight(backwards); + if (excessHeight <= 0) { + return; + } + var itemlist = this.refs.itemlist; + var tiles = itemlist.children; + + // The scroll token of the first/last tile to be unpaginated + let markerScrollToken = null; + + // Subtract clientHeights to simulate the events being unpaginated whilst counting + // the events to be unpaginated. + if (backwards) { + // Iterate forwards from start of tiles, subtracting event tile height + let i = 0; + while (i < tiles.length && excessHeight > tiles[i].clientHeight) { + excessHeight -= tiles[i].clientHeight; + if (tiles[i].dataset.scrollToken) { + markerScrollToken = tiles[i].dataset.scrollToken; + } + i++; + } + } else { + // Iterate backwards from end of tiles, subtracting event tile height + let i = tiles.length - 1; + while (i > 0 && excessHeight > tiles[i].clientHeight) { + excessHeight -= tiles[i].clientHeight; + if (tiles[i].dataset.scrollToken) { + markerScrollToken = tiles[i].dataset.scrollToken; + } + i--; + } + } + + if (markerScrollToken) { + // Use a debouncer to prevent multiple unfill calls in quick succession + // This is to make the unfilling process less aggressive + if (this._unfillDebouncer) { + clearTimeout(this._unfillDebouncer); + } + this._unfillDebouncer = setTimeout(() => { + this._unfillDebouncer = null; + this.props.onUnfillRequest(backwards, markerScrollToken); + }, UNFILL_REQUEST_DEBOUNCE_MS); + } + }, + // check if there is already a pending fill request. If not, set one off. _maybeFill: function(backwards) { var dir = backwards ? 'b' : 'f'; @@ -285,7 +393,7 @@ module.exports = React.createClass({ this._pendingFillRequests[dir] = true; var fillPromise; try { - fillPromise = this.props.onFillRequest(backwards); + fillPromise = this.props.onFillRequest(backwards); } catch (e) { this._pendingFillRequests[dir] = false; throw e; @@ -294,6 +402,12 @@ module.exports = React.createClass({ q.finally(fillPromise, () => { this._pendingFillRequests[dir] = false; }).then((hasMoreResults) => { + if (this.unmounted) { + return; + } + // Unpaginate once filling is complete + this._checkUnfillState(!backwards); + debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); if (hasMoreResults) { // further pagination requests have been disabled until now, so @@ -456,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) { @@ -468,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; } @@ -486,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() { @@ -526,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 06e38ffb5a..490b83f2bf 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -38,7 +38,7 @@ if (DEBUG) { // using bind means that we get to keep useful line numbers in the console var debuglog = console.log.bind(console); } else { - var debuglog = function () {}; + var debuglog = function() {}; } /* @@ -108,7 +108,9 @@ var TimelinePanel = React.createClass({ getDefaultProps: function() { return { - timelineCap: 250, + // By default, disable the timelineCap in favour of unpaginating based on + // event tile heights. (See _unpaginateEvents) + timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', }; }, @@ -245,6 +247,34 @@ var TimelinePanel = React.createClass({ } }, + onMessageListUnfillRequest: function(backwards, scrollToken) { + let dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; + debuglog("TimelinePanel: unpaginating events in direction", dir); + + // All tiles are inserted by MessagePanel to have a scrollToken === eventId + let eventId = scrollToken; + + let marker = this.state.events.findIndex( + (ev) => { + return ev.getId() === eventId; + } + ); + + let count = backwards ? marker + 1 : this.state.events.length - marker; + + if (count > 0) { + debuglog("TimelinePanel: Unpaginating", count, "in direction", dir); + this._timelineWindow.unpaginate(count, backwards); + + // We can now paginate in the unpaginated direction + const canPaginateKey = (backwards) ? 'canBackPaginate' : 'canForwardPaginate'; + this.setState({ + [canPaginateKey]: true, + events: this._getEvents(), + }); + } + }, + // set off a pagination request. onMessageListFillRequest: function(backwards) { var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; @@ -292,7 +322,7 @@ var TimelinePanel = React.createClass({ }); }, - onMessageListScroll: function () { + onMessageListScroll: function() { if (this.props.onScroll) { this.props.onScroll(); } @@ -357,7 +387,7 @@ var TimelinePanel = React.createClass({ // if we're at the end of the live timeline, append the pending events if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(... this.props.timelineSet.room.getPendingEvents()); + events.push(...this.props.timelineSet.room.getPendingEvents()); } var updatedState = {events: events}; @@ -534,8 +564,9 @@ var TimelinePanel = React.createClass({ // first find where the current RM is for (var i = 0; i < events.length; i++) { - if (events[i].getId() == this.state.readMarkerEventId) + if (events[i].getId() == this.state.readMarkerEventId) { break; + } } if (i >= events.length) { return; @@ -614,7 +645,7 @@ var TimelinePanel = React.createClass({ var tl = this.props.timelineSet.getTimelineForEvent(rmId); var rmTs; if (tl) { - var event = tl.getEvents().find((e) => { return e.getId() == rmId }); + var event = tl.getEvents().find((e) => { return e.getId() == rmId; }); if (event) { rmTs = event.getTs(); } @@ -780,7 +811,7 @@ var TimelinePanel = React.createClass({ }); }; } - var message = "Riot was trying to load a specific point in this room's timeline but "; + var message = "Tried to load a specific point in this room's timeline, but "; if (error.errcode == 'M_FORBIDDEN') { message += "you do not have permission to view the message in question."; } else { @@ -791,7 +822,7 @@ var TimelinePanel = React.createClass({ description: message, onFinished: onFinished, }); - } + }; var prom = this._timelineWindow.load(eventId, INITIAL_SIZE); @@ -813,7 +844,7 @@ var TimelinePanel = React.createClass({ timelineLoading: true, }); - prom = prom.then(onLoaded, onError) + prom = prom.then(onLoaded, onError); } prom.done(); @@ -838,7 +869,7 @@ var TimelinePanel = React.createClass({ // if we're at the end of the live timeline, append the pending events if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { - events.push(... this.props.timelineSet.getPendingEvents()); + events.push(...this.props.timelineSet.getPendingEvents()); } return events; @@ -900,8 +931,9 @@ var TimelinePanel = React.createClass({ _getCurrentReadReceipt: function(ignoreSynthesized) { var client = MatrixClientPeg.get(); // the client can be null on logout - if (client == null) + if (client == null) { return null; + } var myUserId = client.credentials.userId; return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); @@ -984,6 +1016,7 @@ var TimelinePanel = React.createClass({ stickyBottom={ stickyBottom } onScroll={ this.onMessageListScroll } onFillRequest={ this.onMessageListFillRequest } + onUnfillRequest={ this.onMessageListUnfillRequest } opacity={ this.props.opacity } className={ this.props.className } tileShape={ this.props.tileShape } diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 794fcffec7..e91e558cb2 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -57,7 +57,7 @@ module.exports = React.createClass({displayName: 'UploadBar', // }]; if (uploads.length == 0) { - return
    + return
    ; } var upload; @@ -68,7 +68,7 @@ module.exports = React.createClass({displayName: 'UploadBar', } } if (!upload) { - return
    + return
    ; } var innerProgressStyle = { @@ -76,7 +76,7 @@ module.exports = React.createClass({displayName: 'UploadBar', }; var uploadedSize = filesize(upload.loaded); var totalSize = filesize(upload.total); - if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) { + if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) { uploadedSize = uploadedSize.replace(/ .*/, ''); } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index c6efc55607..3d330e3649 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -26,12 +26,61 @@ 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', + }, +*/ +]; + +// 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 +92,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, }, @@ -61,6 +113,7 @@ module.exports = React.createClass({ phase: "UserSettings.LOADING", // LOADING, DISPLAY email_add_pending: false, vectorVersion: null, + rejectingInvites: false, }; }, @@ -80,12 +133,24 @@ module.exports = React.createClass({ }); } + // Bulk rejecting invites: + // /sync won't have had time to return when UserSettings re-renders from state changes, so getRooms() + // will still return rooms with invites. To get around this, add a listener for + // membership updates and kick the UI. + MatrixClientPeg.get().on("RoomMember.membership", this._onInviteStateChange); + dis.dispatch({ action: 'ui_opacity', sideOpacity: 0.3, middleOpacity: 0.3, }); this._refreshFromServer(); + + var syncedSettings = UserSettingsStore.getSyncedSettings(); + if (!syncedSettings.theme) { + syncedSettings.theme = 'light'; + } + this._syncedSettings = syncedSettings; }, componentDidMount: function() { @@ -101,6 +166,10 @@ module.exports = React.createClass({ middleOpacity: 1.0, }); dis.unregister(this.dispatcherRef); + let cli = MatrixClientPeg.get(); + if (cli) { + cli.removeListener("RoomMember.membership", this._onInviteStateChange); + } }, _refreshFromServer: function() { @@ -164,8 +233,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) { @@ -237,6 +324,31 @@ module.exports = React.createClass({ this.setState({email_add_pending: true}); }, + onRemoveThreepidClicked: function(threepid) { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createDialog(QuestionDialog, { + title: "Remove Contact Information?", + description: "Remove " + threepid.address + "?", + button: 'Remove', + onFinished: (submit) => { + if (submit) { + this.setState({ + phase: "UserSettings.LOADING", + }); + MatrixClientPeg.get().deleteThreePid(threepid.medium, threepid.address).then(() => { + return this._refreshFromServer(); + }).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Unable to remove contact information", + description: err.toString(), + }); + }).done(); + } + }, + }); + }, + onEmailDialogFinished: function(ok) { if (ok) { this.verifyEmailAddress(); @@ -257,8 +369,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, @@ -280,91 +392,185 @@ 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(); + } + }, + + _onRejectAllInvitesClicked: function(rooms, ev) { + this.setState({ + rejectingInvites: true + }); + // reject the invites + let promises = rooms.map((room) => { + return MatrixClientPeg.get().leave(room.roomId); + }); + // purposefully drop errors to the floor: we'll just have a non-zero number on the UI + // after trying to reject all the invites. + q.allSettled(promises).then(() => { + this.setState({ + rejectingInvites: false + }); + }).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 ( +
    +

    Referral

    +
    + Refer a friend to Riot: {href} +
    +
    + ); + }, + _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() { - if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { - return null; + const client = MatrixClientPeg.get(); + const deviceId = client.deviceId; + const identityKey = client.getDeviceEd25519Key() || ""; + + let exportButton = null, + importButton = null; + + if (client.isCryptoEnabled) { + exportButton = ( + + Export E2E room keys + + ); + importButton = ( + + Import E2E room keys + + ); } - - var client = MatrixClientPeg.get(); - var deviceId = client.deviceId; - var identityKey = client.getDeviceEd25519Key() || ""; - - var myDevice = client.getStoredDevicesForUser(MatrixClientPeg.get().credentials.userId)[0]; return (

    Cryptography

      -
    • { myDevice.getDisplayName() }
    • -
    • {deviceId}
    • -
    • {identityKey}
    • +
    • {deviceId}
    • +
    • {identityKey}
    + {exportButton} + {importButton}
    ); }, _renderDevicesPanel: function() { - if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { - return null; - } var DevicesPanel = sdk.getComponent('settings.DevicesPanel'); return (
    @@ -374,7 +580,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; @@ -410,7 +633,7 @@ module.exports = React.createClass({ {features}
    - ) + ); }, _renderDeactivateAccount: function() { @@ -420,15 +643,49 @@ module.exports = React.createClass({ return

    Deactivate Account

    - +
    ; }, + _renderBulkOptions: function() { + let invitedRooms = MatrixClientPeg.get().getRooms().filter((r) => { + return r.hasMembershipState(this._me, "invite"); + }); + if (invitedRooms.length === 0) { + return null; + } + + let Spinner = sdk.getComponent("elements.Spinner"); + + let reject = ; + if (!this.state.rejectingInvites) { + // 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 = ( + + Reject all {invitedRooms.length} invites + + ); + } + + return
    +

    Bulk Options

    +
    + {reject} +
    +
    ; + }, + + nameForMedium: function(medium) { + if (medium == 'msisdn') return 'Phone'; + return medium[0].toUpperCase() + medium.slice(1); + }, + render: function() { - var self = this; var Loader = sdk.getComponent("elements.Spinner"); switch (this.state.phase) { case "UserSettings.LOADING": @@ -452,15 +709,18 @@ module.exports = React.createClass({ this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null ); - var threepidsSection = this.state.threepids.map(function(val, pidIndex) { - var id = "email-" + val.address; + var threepidsSection = this.state.threepids.map((val, pidIndex) => { + const id = "3pid-" + val.address; return (
    - +
    - + +
    +
    + Remove
    ); @@ -482,7 +742,7 @@ module.exports = React.createClass({ blurToCancel={ false } onValueChanged={ this.onAddThreepidClicked } />
    -
    +
    Add
    @@ -563,7 +823,7 @@ module.exports = React.createClass({
    @@ -576,19 +836,23 @@ module.exports = React.createClass({
    -
    + Sign out -
    + {accountJsx}
    + {this._renderReferral()} + {notification_area} {this._renderUserInterfaceSettings()} {this._renderLabs()} {this._renderDevicesPanel()} {this._renderCryptoInfo()} + {this._renderBulkOptions()} + {this._renderBugReport()}

    Advanced

    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 e8b1a35213..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:' && @@ -203,7 +203,7 @@ module.exports = React.createClass({ !this.state.enteredHomeserverUrl.startsWith("http"))) { errorText = - Can't connect to homeserver via HTTP when using Riot served by HTTPS. + Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. Either use HTTPS or enable unsafe scripts ; } @@ -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 5071a6b4c6..0fc0cac527 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( @@ -88,10 +99,40 @@ module.exports = React.createClass({ this.registerLogic.setIdSid(this.props.idSid); this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); 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 +210,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 +316,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 +334,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": @@ -273,6 +366,7 @@ module.exports = React.createClass({ if (serverParams && serverParams["m.login.recaptcha"]) { publicKey = serverParams["m.login.recaptcha"].public_key; } + registerStep = ( ); @@ -296,7 +389,7 @@ module.exports = React.createClass({ returnToAppJsx = Return to app - + ; } return ( @@ -330,7 +423,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 4025859478..c9c84aa1bf 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -19,6 +19,7 @@ limitations under the License. var React = require('react'); var AvatarLogic = require("../../../Avatar"); import sdk from '../../../index'; +import AccessibleButton from '../elements/AccessibleButton'; module.exports = React.createClass({ displayName: 'BaseAvatar', @@ -41,7 +42,7 @@ module.exports = React.createClass({ height: 40, resizeMethod: 'crop', defaultToInitialLetter: true - } + }; }, getInitialState: function() { @@ -138,7 +139,7 @@ module.exports = React.createClass({ const { name, idName, title, url, urls, width, height, resizeMethod, - defaultToInitialLetter, viewUserOnClick, + defaultToInitialLetter, onClick, ...otherProps } = this.props; @@ -156,12 +157,24 @@ module.exports = React.createClass({ ); } - return ( - - ); + if (onClick != null) { + return ( + + + + ); + } else { + return ( + + ); + } } }); diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 161c37ef55..9fb522a5f1 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -33,6 +33,7 @@ module.exports = React.createClass({ onClick: React.PropTypes.func, // Whether the onClick of the avatar should be overriden to dispatch 'view_user' viewUserOnClick: React.PropTypes.bool, + title: React.PropTypes.string, }, getDefaultProps: function() { @@ -41,7 +42,7 @@ module.exports = React.createClass({ height: 40, resizeMethod: 'crop', viewUserOnClick: false, - } + }; }, getInitialState: function() { @@ -58,26 +59,26 @@ module.exports = React.createClass({ } return { name: props.member.name, - title: props.member.userId, + title: props.title || props.member.userId, imageUrl: Avatar.avatarUrlForMember(props.member, props.width, props.height, props.resizeMethod) - } + }; }, render: function() { var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var {member, onClick, ...otherProps} = this.props; + var {member, onClick, viewUserOnClick, ...otherProps} = this.props; - if (this.props.viewUserOnClick) { + if (viewUserOnClick) { onClick = () => { dispatcher.dispatch({ 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/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 90af1635c9..da3975e4db 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -42,14 +42,14 @@ export default React.createClass({
    • { this.props.device.getDisplayName() }
    • -
    • { this.props.device.deviceId}
    • -
    • { this.props.device.getFingerprint() }
    • +
    • { this.props.device.deviceId}
    • +
    • { this.props.device.getFingerprint() }

    If it matches, press the verify button below. If it doesnt, then someone else is intercepting this device - and you probably want to press the block button instead. + and you probably want to press the blacklist button instead.

    In future this verification process will be more sophisticated. @@ -73,33 +73,33 @@ export default React.createClass({ ); }, - onBlockClick: function() { + onBlacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( this.props.userId, this.props.device.deviceId, true ); }, - onUnblockClick: function() { + onUnblacklistClick: function() { MatrixClientPeg.get().setDeviceBlocked( this.props.userId, this.props.device.deviceId, false ); }, render: function() { - var blockButton = null, verifyButton = null; + var blacklistButton = null, verifyButton = null; if (this.props.device.isBlocked()) { - blockButton = ( - ); } else { - blockButton = ( - ); } @@ -115,7 +115,7 @@ export default React.createClass({ verifyButton = ( ); } @@ -124,7 +124,7 @@ export default React.createClass({ return (

    { verifyButton } - { blockButton } + { blacklistButton }
    ); }, diff --git a/src/components/views/elements/DirectorySearchBox.js b/src/components/views/elements/DirectorySearchBox.js index a453dfb62a..3ea0d16336 100644 --- a/src/components/views/elements/DirectorySearchBox.js +++ b/src/components/views/elements/DirectorySearchBox.js @@ -89,7 +89,7 @@ export default class DirectorySearchBox extends React.Component { return
    - containing the textual summary of the aggregated + * events that occurred. + */ + _renderSummary: function(eventAggregates, orderedTransitionSequences) { + const summaries = orderedTransitionSequences.map((transitions) => { + const userNames = eventAggregates[transitions]; + const nameList = this._renderNameList(userNames); + const plural = userNames.length > 1; - _renderNameList: function(events) { - if (events.length === 0) { + const splitTransitions = transitions.split(','); + + // Some neighbouring transitions are common, so canonicalise some into "pair" + // transitions + const canonicalTransitions = this._getCanonicalTransitions(splitTransitions); + // Transform into consecutive repetitions of the same transition (like 5 + // consecutive 'joined_and_left's) + const coalescedTransitions = this._coalesceRepeatedTransitions( + canonicalTransitions + ); + + const descs = coalescedTransitions.map((t) => { + return this._getDescriptionForTransition( + t.transitionType, plural, t.repeats + ); + }); + + const desc = this._renderCommaSeparatedList(descs); + + return nameList + " " + desc; + }); + + if (!summaries) { return null; } - let originalNumber = events.length; - events = events.slice(0, this.props.summaryLength); - let lastEvent = events.pop(); - let names = events.map((ev) => { - return this._getEventSenderName(ev); - }).join(', '); - - let lastName = this._getEventSenderName(lastEvent); - if (names.length === 0) { - // special-case for a single event - return lastName; - } - - let remaining = originalNumber - this.props.summaryLength; - if (remaining > 0) { - // name1, name2, name3, and 100 others - return names + ', ' + lastName + ', and ' + remaining + ' others'; - } else { - // name1, name2 and name3 - return names + ' and ' + lastName; - } - }, - - _renderSummary: function(joinEvents, leaveEvents) { - let joiners = this._renderNameList(joinEvents); - let leavers = this._renderNameList(leaveEvents); - - let joinSummary = null; - if (joiners) { - joinSummary = ( - - {joiners} joined the room - - ); - } - let leaveSummary = null; - if (leavers) { - leaveSummary = ( - - {leavers} left the room - - ); - } return ( - {joinSummary}{joinSummary && leaveSummary?'; ':''} - {leaveSummary} + {summaries.join(", ")} ); }, - _renderAvatars: function(events) { - let avatars = events.slice(0, this.props.avatarsMaxLength).map((e) => { + /** + * @param {string[]} users an array of user display names or user IDs. + * @returns {string} a comma-separated list that ends with "and [n] others" if there are + * more items in `users` than `this.props.summaryLength`, which is the number of names + * included before "and [n] others". + */ + _renderNameList: function(users) { + return this._renderCommaSeparatedList(users, this.props.summaryLength); + }, + + /** + * Canonicalise an array of transitions such that some pairs of transitions become + * single transitions. For example an input ['joined','left'] would result in an output + * ['joined_and_left']. + * @param {string[]} transitions an array of transitions. + * @returns {string[]} an array of transitions. + */ + _getCanonicalTransitions: function(transitions) { + const modMap = { + 'joined': { + 'after': 'left', + 'newTransition': 'joined_and_left', + }, + 'left': { + 'after': 'joined', + 'newTransition': 'left_and_joined', + }, + // $currentTransition : { + // 'after' : $nextTransition, + // 'newTransition' : 'new_transition_type', + // }, + }; + const res = []; + + for (let i = 0; i < transitions.length; i++) { + const t = transitions[i]; + const t2 = transitions[i + 1]; + + let transition = t; + + if (i < transitions.length - 1 && modMap[t] && modMap[t].after === t2) { + transition = modMap[t].newTransition; + i++; + } + + res.push(transition); + } + return res; + }, + + /** + * Transform an array of transitions into an array of transitions and how many times + * they are repeated consecutively. + * + * An array of 123 "joined_and_left" transitions, would result in: + * ``` + * [{ + * transitionType: "joined_and_left" + * repeats: 123 + * }] + * ``` + * @param {string[]} transitions the array of transitions to transform. + * @returns {object[]} an array of coalesced transitions. + */ + _coalesceRepeatedTransitions: function(transitions) { + const res = []; + for (let i = 0; i < transitions.length; i++) { + if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { + res[res.length - 1].repeats += 1; + } else { + res.push({ + transitionType: transitions[i], + repeats: 1, + }); + } + } + return res; + }, + + /** + * For a certain transition, t, describe what happened to the users that + * underwent the transition. + * @param {string} t the transition type. + * @param {boolean} plural whether there were multiple users undergoing the same + * transition. + * @param {number} repeats the number of times the transition was repeated in a row. + * @returns {string} the written English equivalent of the transition. + */ + _getDescriptionForTransition(t, plural, repeats) { + const beConjugated = plural ? "were" : "was"; + const invitation = "their invitation" + (plural || (repeats > 1) ? "s" : ""); + + let res = null; + const map = { + "joined": "joined", + "left": "left", + "joined_and_left": "joined and left", + "left_and_joined": "left and rejoined", + "invite_reject": "rejected " + invitation, + "invite_withdrawal": "had " + invitation + " withdrawn", + "invited": beConjugated + " invited", + "banned": beConjugated + " banned", + "unbanned": beConjugated + " unbanned", + "kicked": beConjugated + " kicked", + }; + + if (Object.keys(map).includes(t)) { + res = map[t] + (repeats > 1 ? " " + repeats + " times" : "" ); + } + + return res; + }, + + /** + * Constructs a written English string representing `items`, with an optional limit on + * the number of items included in the result. If specified and if the length of + *`items` is greater than the limit, the string "and n others" will be appended onto + * the result. + * If `items` is empty, returns the empty string. If there is only one item, return + * it. + * @param {string[]} items the items to construct a string from. + * @param {number?} itemLimit the number by which to limit the list. + * @returns {string} a string constructed by joining `items` with a comma between each + * item, but with the last item appended as " and [lastItem]". + */ + _renderCommaSeparatedList(items, itemLimit) { + const remaining = itemLimit === undefined ? 0 : Math.max( + items.length - itemLimit, 0 + ); + if (items.length === 0) { + return ""; + } else if (items.length === 1) { + return items[0]; + } else if (remaining) { + items = items.slice(0, itemLimit); + const other = " other" + (remaining > 1 ? "s" : ""); + return items.join(', ') + ' and ' + remaining + other; + } else { + const lastItem = items.pop(); + return items.join(', ') + ' and ' + lastItem; + } + }, + + _renderAvatars: function(roomMembers) { + const avatars = roomMembers.slice(0, this.props.avatarsMaxLength).map((m) => { return ( - + ); }); - return ( {avatars} @@ -134,74 +270,155 @@ module.exports = React.createClass({ ); }, - render: function() { - let summary = null; + _getTransitionSequence: function(events) { + return events.map(this._getTransition); + }, - // Reorder events so that joins come before leaves - let eventsToRender = this.props.events; - - // Filter out those who joined, then left - let filteredEvents = eventsToRender.filter( - (e) => { - return eventsToRender.filter( - (e2) => { - return e.getSender() === e2.getSender() - && e.event.content.membership !== e2.event.content.membership; + /** + * Label a given membership event, `e`, where `getContent().membership` has + * changed for each transition allowed by the Matrix protocol. This attempts to + * label the membership changes that occur in `../../../TextForEvent.js`. + * @param {MatrixEvent} e the membership change event to label. + * @returns {string?} the transition type given to this event. This defaults to `null` + * if a transition is not recognised. + */ + _getTransition: function(e) { + switch (e.mxEvent.getContent().membership) { + case 'invite': return 'invited'; + case 'ban': return 'banned'; + case 'join': return 'joined'; + case 'leave': + if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) { + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return 'invite_reject'; + default: return 'left'; } - ).length === 0; + } + switch (e.mxEvent.getPrevContent().membership) { + case 'invite': return 'invite_withdrawal'; + case 'ban': return 'unbanned'; + case 'join': return 'kicked'; + default: return 'left'; + } + default: return null; + } + }, + + _getAggregate: function(userEvents) { + // A map of aggregate type to arrays of display names. Each aggregate type + // is a comma-delimited string of transitions, e.g. "joined,left,kicked". + // The array of display names is the array of users who went through that + // sequence during eventsToRender. + const aggregate = { + // $aggregateType : []:string + }; + // A map of aggregate types to the indices that order them (the index of + // the first event for a given transition sequence) + const aggregateIndices = { + // $aggregateType : int + }; + + const users = Object.keys(userEvents); + users.forEach( + (userId) => { + const firstEvent = userEvents[userId][0]; + const displayName = firstEvent.displayName; + + const seq = this._getTransitionSequence(userEvents[userId]); + if (!aggregate[seq]) { + aggregate[seq] = []; + aggregateIndices[seq] = -1; + } + + aggregate[seq].push(displayName); + + if (aggregateIndices[seq] === -1 || + firstEvent.index < aggregateIndices[seq]) { + aggregateIndices[seq] = firstEvent.index; + } } ); - let joinAndLeft = (eventsToRender.length - filteredEvents.length) / 2; - if (joinAndLeft <= 0 || joinAndLeft % 1 !== 0) { - joinAndLeft = null; - } + return { + names: aggregate, + indices: aggregateIndices, + }; + }, - let joinEvents = filteredEvents.filter((ev) => { - return ev.event.content.membership === 'join'; - }); + render: function() { + const eventsToRender = this.props.events; + const fewEvents = eventsToRender.length < this.props.threshold; + const expanded = this.state.expanded || fewEvents; - let leaveEvents = filteredEvents.filter((ev) => { - return ev.event.content.membership === 'leave'; - }); - - let fewEvents = eventsToRender.length < this.props.threshold; - let expanded = this.state.expanded || fewEvents; let expandedEvents = null; - if (expanded) { expandedEvents = this.props.children; } - let avatars = this._renderAvatars(joinEvents.concat(leaveEvents)); - - let toggleButton = null; - let summaryContainer = null; - if (!fewEvents) { - summary = this._renderSummary(joinEvents, leaveEvents); - toggleButton = ( - - {expanded ? 'collapse' : 'expand'} - - ); - let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users'; - let noun = (joinAndLeft === 1 ? 'user' : plural); - - summaryContainer = ( -
    -
    - - {avatars} - - - {summary}{joinAndLeft? '. ' + joinAndLeft + ' ' + noun + ' joined and left' : ''} -   - {toggleButton} -
    + if (fewEvents) { + return ( +
    + {expandedEvents}
    ); } + // Map user IDs to an array of objects: + const userEvents = { + // $userId : [{ + // // The original event + // mxEvent: e, + // // The display name of the user (if not, then user ID) + // displayName: e.target.name || userId, + // // The original index of the event in this.props.events + // index: index, + // }] + }; + + const avatarMembers = []; + eventsToRender.forEach((e, index) => { + const userId = e.getStateKey(); + // Initialise a user's events + if (!userEvents[userId]) { + userEvents[userId] = []; + avatarMembers.push(e.target); + } + userEvents[userId].push({ + mxEvent: e, + displayName: e.target.name || userId, + index: index, + }); + }); + + const aggregate = this._getAggregate(userEvents); + + // Sort types by order of lowest event index within sequence + const orderedTransitionSequences = Object.keys(aggregate.names).sort( + (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2] + ); + + const avatars = this._renderAvatars(avatarMembers); + const summary = this._renderSummary(aggregate.names, orderedTransitionSequences); + const toggleButton = ( + + {expanded ? 'collapse' : 'expand'} + + ); + + const summaryContainer = ( +
    +
    + + {avatars} + + + {summary} +   + {toggleButton} +
    +
    + ); + return (
    {summaryContainer} diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index 993f2b965a..c7bfd4eec1 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -73,7 +73,7 @@ module.exports = React.createClass({ getValue: function() { var value; if (this.refs.select) { - value = reverseRoles[ this.refs.select.value ]; + value = reverseRoles[this.refs.select.value]; if (this.refs.custom) { if (value === undefined) value = parseInt( this.refs.custom.value ); } @@ -86,10 +86,10 @@ module.exports = React.createClass({ if (this.state.custom) { var input; if (this.props.disabled) { - input = { this.props.value } + input = { this.props.value }; } else { - input = + input = ; } customPicker = of { input }; } @@ -115,7 +115,7 @@ module.exports = React.createClass({ - + ; } return ( diff --git a/src/components/views/elements/ProgressBar.js b/src/components/views/elements/ProgressBar.js index 12b34480f1..a39e8e48f9 100644 --- a/src/components/views/elements/ProgressBar.js +++ b/src/components/views/elements/ProgressBar.js @@ -35,4 +35,4 @@ module.exports = React.createClass({
    ); } -}); \ No newline at end of file +}); diff --git a/src/components/views/elements/TintableSvg.js b/src/components/views/elements/TintableSvg.js index e8be5f3415..401a11c1cb 100644 --- a/src/components/views/elements/TintableSvg.js +++ b/src/components/views/elements/TintableSvg.js @@ -69,9 +69,19 @@ var TintableSvg = React.createClass({ width={ this.props.width } height={ this.props.height } onLoad={ this.onLoad } + tabIndex="-1" /> ); } }); +// Register with the Tinter so that we will be told if the tint changes +Tinter.registerTintable(function() { + if (TintableSvg.mounts) { + Object.keys(TintableSvg.mounts).forEach((id) => { + TintableSvg.mounts[id].tint(); + }); + } +}); + module.exports = TintableSvg; diff --git a/src/components/views/elements/TruncatedList.js b/src/components/views/elements/TruncatedList.js index 3e174848d3..0ec2c15f0a 100644 --- a/src/components/views/elements/TruncatedList.js +++ b/src/components/views/elements/TruncatedList.js @@ -55,7 +55,7 @@ module.exports = React.createClass({ overflowJsx = this.props.createOverflowElement( overflowCount, childCount ); - + // cut out the overflow elements childArray.splice(childCount - overflowCount, overflowCount); childsJsx = childArray; // use what is left diff --git a/src/components/views/elements/UserSelector.js b/src/components/views/elements/UserSelector.js index 5f176a3e54..266e10154f 100644 --- a/src/components/views/elements/UserSelector.js +++ b/src/components/views/elements/UserSelector.js @@ -56,7 +56,7 @@ module.exports = React.createClass({
      {this.props.selected_users.map(function(user_id, i) { - return
    • {user_id} - X
    • + return
    • {user_id} - X
    • ; })}
    diff --git a/src/components/views/login/CaptchaForm.js b/src/components/views/login/CaptchaForm.js index 0e5922f464..0977f947aa 100644 --- a/src/components/views/login/CaptchaForm.js +++ b/src/components/views/login/CaptchaForm.js @@ -52,12 +52,24 @@ module.exports = React.createClass({ this._onCaptchaLoaded(); } else { console.log("Loading recaptcha script..."); - var scriptTag = document.createElement('script'); - window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()}; - scriptTag.setAttribute( - 'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit" - ); - this.refs.recaptchaContainer.appendChild(scriptTag); + window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();}; + var protocol = global.location.protocol; + if (protocol === "file:") { + var warning = document.createElement('div'); + // XXX: fix hardcoded app URL. Better solutions include: + // * jumping straight to a hosted captcha page (but we don't support that yet) + // * embedding the captcha in an iframe (if that works) + // * using a better captcha lib + warning.innerHTML = "Robot check is currently unavailable on desktop - please use a web browser."; + this.refs.recaptchaContainer.appendChild(warning); + } + else { + var scriptTag = document.createElement('script'); + scriptTag.setAttribute( + 'src', protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit" + ); + this.refs.recaptchaContainer.appendChild(scriptTag); + } } }, @@ -89,7 +101,7 @@ module.exports = React.createClass({ } catch (e) { this.setState({ errorText: e.toString(), - }) + }); } }, @@ -106,6 +118,7 @@ module.exports = React.createClass({ return (
    This Home Server would like to make sure you are not a robot +
    {error}
    diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index 23e2b442ef..ec184ca09f 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -70,7 +70,7 @@ export const PasswordAuthEntry = React.createClass({ }); }, - _onPasswordFieldChange: function (ev) { + _onPasswordFieldChange: function(ev) { // enable the submit button iff the password is non-empty this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); }, @@ -209,4 +209,4 @@ export function getEntryComponentForLoginType(loginType) { } } return FallbackAuthEntry; -}; +} diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 33809fbfd6..1cb8253812 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -38,6 +38,16 @@ module.exports = React.createClass({ defaultEmail: React.PropTypes.string, defaultUsername: React.PropTypes.string, defaultPassword: React.PropTypes.string, + teamsConfig: React.PropTypes.shape({ + // Email address to request new teams + supportEmail: React.PropTypes.string, + teams: React.PropTypes.arrayOf(React.PropTypes.shape({ + // The displayed name of the team + "name": React.PropTypes.string, + // The domain of team email addresses + "domain": React.PropTypes.string, + })).required, + }), // A username that will be used if no username is entered. // Specifying this param will also warn the user that entering @@ -62,7 +72,8 @@ module.exports = React.createClass({ getInitialState: function() { return { - fieldValid: {} + fieldValid: {}, + selectedTeam: null, }; }, @@ -105,10 +116,11 @@ module.exports = React.createClass({ }, _doSubmit: function() { + let email = this.refs.email.value.trim(); var promise = this.props.onRegisterClick({ username: this.refs.username.value.trim() || this.props.guestUsername, password: this.refs.password.value.trim(), - email: this.refs.email.value.trim() + email: email, }); if (promise) { @@ -133,17 +145,37 @@ module.exports = React.createClass({ return true; }, + _isUniEmail: function(email) { + return email.endsWith('.ac.uk') || email.endsWith('.edu'); + }, + validateField: function(field_id) { var pwd1 = this.refs.password.value.trim(); - var pwd2 = this.refs.passwordConfirm.value.trim() + var pwd2 = this.refs.passwordConfirm.value.trim(); switch (field_id) { case FIELD_EMAIL: - this.markFieldValid( - field_id, - this.refs.email.value == '' || Email.looksValid(this.refs.email.value), - "RegistrationForm.ERR_EMAIL_INVALID" - ); + const email = this.refs.email.value; + if (this.props.teamsConfig && this._isUniEmail(email)) { + const matchingTeam = this.props.teamsConfig.teams.find( + (team) => { + return email.split('@').pop() === team.domain; + } + ) || null; + this.setState({ + selectedTeam: matchingTeam, + showSupportEmail: !matchingTeam, + }); + this.props.onTeamSelected(matchingTeam); + } else { + this.props.onTeamSelected(null); + this.setState({ + selectedTeam: null, + showSupportEmail: false, + }); + } + const valid = email === '' || Email.looksValid(email); + this.markFieldValid(field_id, valid, "RegistrationForm.ERR_EMAIL_INVALID"); break; case FIELD_USERNAME: // XXX: SPEC-1 @@ -224,15 +256,36 @@ module.exports = React.createClass({ render: function() { var self = this; - var emailSection, registerButton; + var emailSection, belowEmailSection, registerButton; if (this.props.showEmail) { emailSection = ( + onBlur={function() {self.validateField(FIELD_EMAIL);}} + value={self.state.email}/> ); + if (this.props.teamsConfig) { + if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) { + belowEmailSection = ( +

    + Sorry, but your university is not registered with us just yet.  + Email us on  + + {this.props.teamsConfig.supportEmail} +   + to get your university signed up. Or continue to register with Riot to enjoy our open source platform. +

    + ); + } else if (this.state.selectedTeam) { + belowEmailSection = ( +

    + You are registering with {this.state.selectedTeam.name} +

    + ); + } + } } if (this.props.onRegisterClick) { registerButton = ( @@ -242,31 +295,31 @@ module.exports = React.createClass({ var placeholderUserName = "User name"; if (this.props.guestUsername) { - placeholderUserName += " (default: " + this.props.guestUsername + ")" + placeholderUserName += " (default: " + this.props.guestUsername + ")"; } return (
    {emailSection} -
    + {belowEmailSection} + onBlur={function() {self.validateField(FIELD_USERNAME);}} />
    { this.props.guestUsername ?
    Setting a user name will create a fresh account
    : null }

    {registerButton} diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index a18cfbbcef..4e6ed12f9e 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -64,10 +64,10 @@ module.exports = React.createClass({ hs_url: this.props.customHsUrl, is_url: this.props.customIsUrl, // if withToggleButton is false, then show the config all the time given we have no way otherwise of making it visible - configVisible: !this.props.withToggleButton || + configVisible: !this.props.withToggleButton || (this.props.customHsUrl !== this.props.defaultHsUrl) || (this.props.customIsUrl !== this.props.defaultIsUrl) - } + }; }, onHomeserverChanged: function(ev) { diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js index ff753621c7..73b9bdb200 100644 --- a/src/components/views/messages/MAudioBody.js +++ b/src/components/views/messages/MAudioBody.js @@ -21,7 +21,7 @@ import MFileBody from './MFileBody'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; -import { decryptFile } from '../../../utils/DecryptFile'; +import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile'; export default class MAudioBody extends React.Component { constructor(props) { @@ -29,7 +29,9 @@ export default class MAudioBody extends React.Component { this.state = { playing: false, decryptedUrl: null, - } + decryptedBlob: null, + error: null, + }; } onPlayToggle() { this.setState({ @@ -49,29 +51,45 @@ export default class MAudioBody extends React.Component { componentDidMount() { var content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { - decryptFile(content.file).done((url) => { + var decryptedBlob; + decryptFile(content.file).then(function(blob) { + decryptedBlob = blob; + return readBlobAsDataUri(decryptedBlob); + }).done((url) => { this.setState({ - decryptedUrl: url + decryptedUrl: url, + decryptedBlob: decryptedBlob, }); }, (err) => { - console.warn("Unable to decrypt attachment: ", err) - // Set a placeholder image when we can't decrypt the image. - this.refs.image.src = "img/warning.svg"; + console.warn("Unable to decrypt attachment: ", err); + this.setState({ + error: err, + }); }); } } render() { + const content = this.props.mxEvent.getContent(); + if (this.state.error !== null) { + return ( + + + Error decrypting audio + + ); + } + if (content.file !== undefined && this.state.decryptedUrl === null) { // Need to decrypt the attachment // The attachment is decrypted in componentDidMount. - // For now add an img tag with a spinner. + // For now add an img tag with a 16x16 spinner. + // Not sure how tall the audio player is so not sure how tall it should actually be. return ( - {content.body} + {content.body} ); } @@ -81,7 +99,7 @@ export default class MAudioBody extends React.Component { return ( ); } diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index e8c97e5f44..86aee28269 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -21,120 +21,332 @@ import filesize from 'filesize'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import {decryptFile} from '../../../utils/DecryptFile'; +import Tinter from '../../../Tinter'; +import request from 'browser-request'; +import q from 'q'; +import Modal from '../../../Modal'; +// A cached tinted copy of "img/download.svg" +var tintedDownloadImageURL; +// Track a list of mounted MFileBody instances so that we can update +// the "img/download.svg" when the tint changes. +var nextMountId = 0; +const mounts = {}; + +/** + * Updates the tinted copy of "img/download.svg" when the tint changes. + */ +function updateTintedDownloadImage() { + // Download the svg as an XML document. + // We could cache the XML response here, but since the tint rarely changes + // it's probably not worth it. + // Also note that we can't use fetch here because fetch doesn't support + // file URLs, which the download image will be if we're running from + // the filesystem (like in an Electron wrapper). + request({uri: "img/download.svg"}, (err, response, body) => { + if (err) return; + + const svg = new DOMParser().parseFromString(body, "image/svg+xml"); + // Apply the fixups to the XML. + const fixups = Tinter.calcSvgFixups([{contentDocument: svg}]); + Tinter.applySvgFixups(fixups); + // Encoded the fixed up SVG as a data URL. + const svgString = new XMLSerializer().serializeToString(svg); + tintedDownloadImageURL = "data:image/svg+xml;base64," + window.btoa(svgString); + // Notify each mounted MFileBody that the URL has changed. + Object.keys(mounts).forEach(function(id) { + mounts[id].tint(); + }); + }); +} + +Tinter.registerTintable(updateTintedDownloadImage); + +// User supplied content can contain scripts, we have to be careful that +// we don't accidentally run those script within the same origin as the +// client. Otherwise those scripts written by remote users can read +// the access token and end-to-end keys that are in local storage. +// +// For attachments downloaded directly from the homeserver we can use +// Content-Security-Policy headers to disable script execution. +// +// But attachments with end-to-end encryption are more difficult to handle. +// We need to decrypt the attachment on the client and then display it. +// To display the attachment we need to turn the decrypted bytes into a URL. +// +// There are two ways to turn bytes into URLs, data URL and blob URLs. +// Data URLs aren't suitable for downloading a file because Chrome has a +// 2MB limit on the size of URLs that can be viewed in the browser or +// downloaded. This limit does not seem to apply when the url is used as +// the source attribute of an image tag. +// +// Blob URLs are generated using window.URL.createObjectURL and unforuntately +// for our purposes they inherit the origin of the page that created them. +// This means that any scripts that run when the URL is viewed will be able +// to access local storage. +// +// The easiest solution is to host the code that generates the blob URL on +// a different domain to the client. +// Another possibility is to generate the blob URL within a sandboxed iframe. +// The downside of using a second domain is that it complicates hosting, +// the downside of using a sandboxed iframe is that the browers are overly +// restrictive in what you are allowed to do with the generated URL. +// +// For now given how unusable the blobs generated in sandboxed iframes are we +// default to using a renderer hosted on "usercontent.riot.im". This is +// overridable so that people running their own version of the client can +// choose a different renderer. +// +// To that end the first version of the blob generation will be the following +// html: +// +// +// +// This waits to receive a message event sent using the window.postMessage API. +// When it receives the event it evals a javascript function in data.code and +// runs the function passing the event as an argument. +// +// In particular it means that the rendering function can be written as a +// ordinary javascript function which then is turned into a string using +// toString(). +// +const DEFAULT_CROSS_ORIGIN_RENDERER = "https://usercontent.riot.im/v1.html"; + +/** + * Render the attachment inside the iframe. + * We can't use imported libraries here so this has to be vanilla JS. + */ +function remoteRender(event) { + const data = event.data; + + const img = document.createElement("img"); + img.id = "img"; + img.src = data.imgSrc; + + const a = document.createElement("a"); + a.id = "a"; + a.rel = data.rel; + a.target = data.target; + a.download = data.download; + a.style = data.style; + a.href = window.URL.createObjectURL(data.blob); + a.appendChild(img); + a.appendChild(document.createTextNode(data.textContent)); + + const body = document.body; + // Don't display scrollbars if the link takes more than one line + // to display. + body.style = "margin: 0px; overflow: hidden"; + body.appendChild(a); +} + +/** + * Update the tint inside the iframe. + * We can't use imported libraries here so this has to be vanilla JS. + */ +function remoteSetTint(event) { + const data = event.data; + + const img = document.getElementById("img"); + img.src = data.imgSrc; + img.style = data.imgStyle; + + const a = document.getElementById("a"); + a.style = data.style; +} + + +/** + * Get the current CSS style for a DOMElement. + * @param {HTMLElement} element The element to get the current style of. + * @return {string} The CSS style encoded as a string. + */ +function computedStyle(element) { + if (!element) { + return ""; + } + const style = window.getComputedStyle(element, null); + var cssText = style.cssText; + if (cssText == "") { + // Firefox doesn't implement ".cssText" for computed styles. + // https://bugzilla.mozilla.org/show_bug.cgi?id=137687 + for (var i = 0; i < style.length; i++) { + cssText += style[i] + ":"; + cssText += style.getPropertyValue(style[i]) + ";"; + } + } + return cssText; +} + module.exports = React.createClass({ displayName: 'MFileBody', getInitialState: function() { return { - decryptedUrl: (this.props.decryptedUrl ? this.props.decryptedUrl : null), + decryptedBlob: (this.props.decryptedBlob ? this.props.decryptedBlob : null), }; }, + contextTypes: { + appConfig: React.PropTypes.object, + }, + + /** + * Extracts a human readable label for the file attachment to use as + * link text. + * + * @params {Object} content The "content" key of the matrix event. + * @return {string} the human readable link text for the attachment. + */ presentableTextForFile: function(content) { var linkText = 'Attachment'; if (content.body && content.body.length > 0) { + // The content body should be the name of the file including a + // file extension. linkText = content.body; } - var additionals = []; - if (content.info) { - // if (content.info.mimetype && content.info.mimetype.length > 0) { - // additionals.push(content.info.mimetype); - // } - if (content.info.size) { - additionals.push(filesize(content.info.size)); - } - } - - if (additionals.length > 0) { - linkText += ' (' + additionals.join(', ') + ')'; + if (content.info && content.info.size) { + // If we know the size of the file then add it as human readable + // string to the end of the link text so that the user knows how + // big a file they are downloading. + // The content.info also contains a MIME-type but we don't display + // it since it is "ugly", users generally aren't aware what it + // means and the type of the attachment can usually be inferrered + // from the file extension. + linkText += ' (' + filesize(content.info.size) + ')'; } return linkText; }, _getContentUrl: function() { const content = this.props.mxEvent.getContent(); - if (content.file !== undefined) { - return this.state.decryptedUrl; - } else { - return MatrixClientPeg.get().mxcUrlToHttp(content.url); - } + return MatrixClientPeg.get().mxcUrlToHttp(content.url); }, componentDidMount: function() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined && this.state.decryptedUrl === null) { - decryptFile(content.file).done((url) => { - this.setState({ - decryptedUrl: url, - }); - }, (err) => { - console.warn("Unable to decrypt attachment: ", err) - // Set a placeholder image when we can't decrypt the image. - this.refs.image.src = "img/warning.svg"; - }); + // Add this to the list of mounted components to receive notifications + // when the tint changes. + this.id = nextMountId++; + mounts[this.id] = this; + this.tint(); + }, + + componentWillUnmount: function() { + // Remove this from the list of mounted components + delete mounts[this.id]; + }, + + tint: function() { + // Update our tinted copy of "img/download.svg" + if (this.refs.downloadImage) { + this.refs.downloadImage.src = tintedDownloadImageURL; + } + if (this.refs.iframe) { + // If the attachment is encrypted then the download image + // will be inside the iframe so we wont be able to update + // it directly. + this.refs.iframe.contentWindow.postMessage({ + code: remoteSetTint.toString(), + imgSrc: tintedDownloadImageURL, + style: computedStyle(this.refs.dummyLink), + }, "*"); } }, render: function() { const content = this.props.mxEvent.getContent(); - const text = this.presentableTextForFile(content); + const isEncrypted = content.file !== undefined; + const fileName = content.body && content.body.length > 0 ? content.body : "Attachment"; + const contentUrl = this._getContentUrl(); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - var TintableSvg = sdk.getComponent("elements.TintableSvg"); - if (content.file !== undefined && this.state.decryptedUrl === null) { + if (isEncrypted) { + if (this.state.decryptedBlob === null) { + // Need to decrypt the attachment + // Wait for the user to click on the link before downloading + // and decrypting the attachment. + var decrypting = false; + const decrypt = () => { + if (decrypting) { + return false; + } + decrypting = true; + decryptFile(content.file).then((blob) => { + this.setState({ + decryptedBlob: blob, + }); + }).catch((err) => { + console.warn("Unable to decrypt attachment: ", err); + Modal.createDialog(ErrorDialog, { + description: "Error decrypting attachment" + }); + }).finally(() => { + decrypting = false; + return; + }); + }; - // Need to decrypt the attachment - // The attachment is decrypted in componentDidMount. - // For now add an img tag with a spinner. + return ( + + + + ); + } + + // When the iframe loads we tell it to render a download link + const onIframeLoad = (ev) => { + ev.target.contentWindow.postMessage({ + code: remoteRender.toString(), + imgSrc: tintedDownloadImageURL, + style: computedStyle(this.refs.dummyLink), + blob: this.state.decryptedBlob, + // Set a download attribute for encrypted files so that the file + // will have the correct name when the user tries to download it. + // We can't provide a Content-Disposition header like we would for HTTP. + download: fileName, + target: "_blank", + textContent: "Download " + text, + }, "*"); + }; + + // If the attachment is encryped then put the link inside an iframe. + let renderer_url = DEFAULT_CROSS_ORIGIN_RENDERER; + if (this.context.appConfig && this.context.appConfig.cross_origin_renderer_url) { + renderer_url = this.context.appConfig.cross_origin_renderer_url; + } return ( - - {content.body} + +
    +
    + {/* + * Add dummy copy of the "a" tag + * We'll use it to learn how the download link + * would have been styled if it was rendered inline. + */} + +
    +