Merge branch 'develop' into luke/rts-welcome-pages

Conflicts:
	src/components/views/avatars/BaseAvatar.js
This commit is contained in:
Luke Barnard 2017-02-01 17:22:45 +00:00
commit acde1f3db7
153 changed files with 5544 additions and 1599 deletions

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
src/component-index.js

117
.eslintrc
View file

@ -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
}
}
}

75
.eslintrc.js Normal file
View file

@ -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
},
},
};

25
.travis-test-riot.sh Executable file
View file

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

View file

@ -1,3 +1,9 @@
language: node_js language: node_js
node_js: node_js:
- node # Latest stable version of nodejs. - 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

View file

@ -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) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.7.5-rc.1...v0.7.5)

View file

@ -12,17 +12,17 @@ a 'skin'. A skin provides:
* Zero or more 'modules' containing non-UI functionality * Zero or more 'modules' containing non-UI functionality
**WARNING: As of July 2016, the skinning abstraction is broken due to rapid **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 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/vector-web). 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 `vector-web` Right now `matrix-react-sdk` depends on some functionality from `riot-web`
(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour (e.g. CSS), and `matrix-react-sdk` contains some Riot specific behaviour
(grep for 'vector'). This layering will be fixed asap once Vector development (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 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. 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 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 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: Please follow the Matrix JS/React code style as per:
https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst 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: (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 * 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 * In practice, `matrix-react-sdk` is still evolving so fast that the maintenance
burden of customising and overriding these components for Vector can seriously 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 Vector. 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/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk * CSS for Matrix SDK components: https://github.com/vector-im/riot-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 * 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: React components in matrix-react-sdk are come in two different flavours:
'structures' and 'views'. Structures are stateful components which handle the '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). * The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css).
CSS for matrix-react-sdk currently resides in 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 * 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 the context of the rest of the app, although this is unusual for any but
@ -129,7 +129,7 @@ from it.
Github Issues 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. for now.
OUTDATED: To Create Your Own Skin OUTDATED: To Create Your Own Skin

View file

@ -12,11 +12,14 @@ set -x
# install the other dependencies # install the other dependencies
npm install 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 # run the mocha tests
npm run test npm run test
# run eslint # 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 # delete the old tarball, if it exists
rm -f matrix-react-sdk-*.tgz rm -f matrix-react-sdk-*.tgz

View file

@ -165,6 +165,14 @@ module.exports = function (config) {
}, },
devtool: 'inline-source-map', devtool: 'inline-source-map',
}, },
webpackMiddleware: {
stats: {
// don't fill the console up with a mahoosive list of modules
chunks: false,
},
},
browserNoActivityTimeout: 15000, browserNoActivityTimeout: 15000,
}); });
}; };

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.7.5", "version": "0.8.5",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -10,6 +10,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"main": "lib/index.js", "main": "lib/index.js",
"files": [ "files": [
".eslintrc.js",
"CHANGELOG.md", "CHANGELOG.md",
"CONTRIBUTING.rst", "CONTRIBUTING.rst",
"LICENSE", "LICENSE",
@ -42,13 +43,16 @@
}, },
"dependencies": { "dependencies": {
"babel-runtime": "^6.11.6", "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", "browser-request": "^0.3.3",
"classnames": "^2.1.2", "classnames": "^2.1.2",
"commonmark": "^0.27.0",
"draft-js": "^0.8.1", "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", "draft-js-export-markdown": "^0.2.0",
"emojione": "2.2.3", "emojione": "2.2.3",
"file-saver": "^1.3.3",
"filesize": "^3.1.2", "filesize": "^3.1.2",
"flux": "^2.0.3", "flux": "^2.0.3",
"fuse.js": "^2.2.0", "fuse.js": "^2.2.0",
@ -57,22 +61,22 @@
"isomorphic-fetch": "^2.2.1", "isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.3", "linkifyjs": "^2.1.3",
"lodash": "^4.13.1", "lodash": "^4.13.1",
"marked": "^0.3.5",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.2.1", "react": "^15.4.0",
"react-addons-css-transition-group": "^15.2.1", "react-addons-css-transition-group": "15.3.2",
"react-dom": "^15.2.1", "react-dom": "^15.4.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"text-encoding-utf-8": "^1.0.1",
"velocity-vector": "vector-im/velocity#059e3b2", "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0" "whatwg-fetch": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"babel-cli": "^6.5.2", "babel-cli": "^6.5.2",
"babel-core": "^6.14.0", "babel-core": "^6.14.0",
"babel-eslint": "^6.1.0", "babel-eslint": "^6.1.2",
"babel-loader": "^6.2.5", "babel-loader": "^6.2.5",
"babel-plugin-add-module-exports": "^0.2.1", "babel-plugin-add-module-exports": "^0.2.1",
"babel-plugin-transform-async-to-generator": "^6.16.0", "babel-plugin-transform-async-to-generator": "^6.16.0",
@ -84,9 +88,10 @@
"babel-preset-es2016": "^6.11.3", "babel-preset-es2016": "^6.11.3",
"babel-preset-es2017": "^6.14.0", "babel-preset-es2017": "^6.14.0",
"babel-preset-react": "^6.11.1", "babel-preset-react": "^6.11.1",
"eslint": "^2.13.1", "eslint": "^3.13.1",
"eslint-plugin-flowtype": "^2.17.0", "eslint-config-google": "^0.7.1",
"eslint-plugin-react": "^6.2.1", "eslint-plugin-flowtype": "^2.30.0",
"eslint-plugin-react": "^6.9.0",
"expect": "^1.16.0", "expect": "^1.16.0",
"json-loader": "^0.5.3", "json-loader": "^0.5.3",
"karma": "^0.13.22", "karma": "^0.13.22",
@ -99,7 +104,7 @@
"karma-webpack": "^1.7.0", "karma-webpack": "^1.7.0",
"mocha": "^2.4.5", "mocha": "^2.4.5",
"phantomjs-prebuilt": "^2.1.7", "phantomjs-prebuilt": "^2.1.7",
"react-addons-test-utils": "^15.0.1", "react-addons-test-utils": "^15.4.0",
"require-json": "0.0.1", "require-json": "0.0.1",
"rimraf": "^2.4.3", "rimraf": "^2.4.3",
"sinon": "^1.17.3", "sinon": "^1.17.3",

View file

@ -49,12 +49,12 @@ module.exports = {
}, },
defaultAvatarUrlForString: function(s) { defaultAvatarUrlForString: function(s) {
var images = [ '76cfa6', '50e2c2', 'f4c371' ]; var images = ['76cfa6', '50e2c2', 'f4c371'];
var total = 0; var total = 0;
for (var i = 0; i < s.length; ++i) { for (var i = 0; i < s.length; ++i) {
total += s.charCodeAt(i); total += s.charCodeAt(i);
} }
return 'img/' + images[total % images.length] + '.png'; return 'img/' + images[total % images.length] + '.png';
} }
} };

View file

@ -41,7 +41,7 @@ export default class BasePlatform {
* Returns true if the platform supports displaying * Returns true if the platform supports displaying
* notifications, otherwise false. * notifications, otherwise false.
*/ */
supportsNotifications() : boolean { supportsNotifications(): boolean {
return false; return false;
} }
@ -49,7 +49,7 @@ export default class BasePlatform {
* Returns true if the application currently has permission * Returns true if the application currently has permission
* to display notifications. Otherwise false. * to display notifications. Otherwise false.
*/ */
maySendNotifications() : boolean { maySendNotifications(): boolean {
return false; return false;
} }
@ -60,10 +60,10 @@ export default class BasePlatform {
* that is 'granted' if the user allowed the request or * that is 'granted' if the user allowed the request or
* 'denied' otherwise. * 'denied' otherwise.
*/ */
requestNotificationPermission() : Promise<string> { requestNotificationPermission(): Promise<string> {
} }
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() { getAppVersion() {
throw new Error("getAppVersion not implemented!"); 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";
}
} }

View file

@ -52,6 +52,7 @@ limitations under the License.
*/ */
var MatrixClientPeg = require('./MatrixClientPeg'); var MatrixClientPeg = require('./MatrixClientPeg');
var PlatformPeg = require("./PlatformPeg");
var Modal = require('./Modal'); var Modal = require('./Modal');
var sdk = require('./index'); var sdk = require('./index');
var Matrix = require("matrix-js-sdk"); var Matrix = require("matrix-js-sdk");
@ -158,10 +159,10 @@ function _setCallState(call, roomId, status) {
calls[roomId] = call; calls[roomId] = call;
if (status === "ringing") { if (status === "ringing") {
play("ringAudio") play("ringAudio");
} }
else if (call && call.call_state === "ringing") { else if (call && call.call_state === "ringing") {
pause("ringAudio") pause("ringAudio");
} }
if (call) { if (call) {
@ -187,6 +188,17 @@ function _onAction(payload) {
); );
} }
else if (payload.type === 'screensharing') { 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( newCall.placeScreenSharingCall(
payload.remote_element, payload.remote_element,
payload.local_element payload.local_element
@ -280,7 +292,7 @@ function _onAction(payload) {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
Modal.createDialog(QuestionDialog, { Modal.createDialog(QuestionDialog, {
title: "Warning!", 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=>{ onFinished: confirm=>{
if (confirm) { if (confirm) {
ConferenceHandler.createNewMatrixCall( ConferenceHandler.createNewMatrixCall(

View file

@ -25,22 +25,87 @@ var Modal = require('./Modal');
var encrypt = require("browser-encrypt-attachment"); var encrypt = require("browser-encrypt-attachment");
function infoForImageFile(imageFile) { // Polyfill for Canvas.toBlob API using Canvas.toDataURL
var deferred = q.defer(); 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 // 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) { reader.onload = function(e) {
img.src = e.target.result; img.src = e.target.result;
// Once ready, returns its size // Once ready, create a thumbnail
img.onload = function() { img.onload = function() {
deferred.resolve({ deferred.resolve(img);
w: img.width,
h: img.height
});
}; };
img.onerror = function(e) { img.onerror = function(e) {
deferred.reject(e); deferred.reject(e);
@ -54,22 +119,53 @@ function infoForImageFile(imageFile) {
return deferred.promise; 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 // 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) { reader.onload = function(e) {
video.src = e.target.result; video.src = e.target.result;
// Once ready, returns its size // Once ready, returns its size
video.onloadedmetadata = function() { // Wait until we have enough data to thumbnail the first frame.
deferred.resolve({ video.onloadeddata = function() {
w: video.videoWidth, deferred.resolve(video);
h: video.videoHeight
});
}; };
video.onerror = function(e) { video.onerror = function(e) {
deferred.reject(e); deferred.reject(e);
@ -83,6 +179,30 @@ function infoForVideoFile(videoFile) {
return deferred.promise; 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. * Read the file as an ArrayBuffer.
* @return {Promise} A promise that resolves with an ArrayBuffer when the file * @return {Promise} A promise that resolves with an ArrayBuffer when the file
@ -101,6 +221,52 @@ function readFileAsArrayBuffer(file) {
return deferred.promise; 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 { class ContentMessages {
constructor() { constructor() {
@ -109,7 +275,7 @@ class ContentMessages {
} }
sendContentToRoom(file, roomId, matrixClient) { sendContentToRoom(file, roomId, matrixClient) {
var content = { const content = {
body: file.name, body: file.name,
info: { info: {
size: file.size, size: file.size,
@ -121,13 +287,14 @@ class ContentMessages {
content.info.mimetype = file.type; content.info.mimetype = file.type;
} }
var def = q.defer(); const def = q.defer();
if (file.type.indexOf('image/') == 0) { if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image'; content.msgtype = 'm.image';
infoForImageFile(file).then(imageInfo=>{ infoForImageFile(matrixClient, roomId, file).then(imageInfo=>{
extend(content.info, imageInfo); extend(content.info, imageInfo);
def.resolve(); def.resolve();
}, error=>{ }, error=>{
console.error(error);
content.msgtype = 'm.file'; content.msgtype = 'm.file';
def.resolve(); def.resolve();
}); });
@ -136,7 +303,7 @@ class ContentMessages {
def.resolve(); def.resolve();
} else if (file.type.indexOf('video/') == 0) { } else if (file.type.indexOf('video/') == 0) {
content.msgtype = 'm.video'; content.msgtype = 'm.video';
infoForVideoFile(file).then(videoInfo=>{ infoForVideoFile(matrixClient, roomId, file).then(videoInfo=>{
extend(content.info, videoInfo); extend(content.info, videoInfo);
def.resolve(); def.resolve();
}, error=>{ }, error=>{
@ -148,36 +315,27 @@ class ContentMessages {
def.resolve(); def.resolve();
} }
var upload = { const upload = {
fileName: file.name, fileName: file.name,
roomId: roomId, roomId: roomId,
total: 0, total: 0,
loaded: 0 loaded: 0,
}; };
this.inprogress.push(upload); this.inprogress.push(upload);
dis.dispatch({action: 'upload_started'}); dis.dispatch({action: 'upload_started'});
var encryptInfo = null;
var error; var error;
var self = this;
return def.promise.then(function() { return def.promise.then(function() {
if (matrixClient.isRoomEncrypted(roomId)) { // XXX: upload.promise must be the promise that
// If the room is encrypted then encrypt the file before uploading it. // is returned by uploadFile as it has an abort()
// First read the file into memory. // method hacked onto it.
upload.promise = readFileAsArrayBuffer(file).then(function(data) { upload.promise = uploadFile(
// Then encrypt the file. matrixClient, roomId, file
return encrypt.encryptAttachment(data); );
}).then(function(encryptResult) { return upload.promise.then(function(result) {
// Record the information needed to decrypt the attachment. content.file = result.file;
encryptInfo = encryptResult.info; content.url = result.url;
// 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;
}).progress(function(ev) { }).progress(function(ev) {
if (ev) { if (ev) {
upload.total = ev.total; upload.total = ev.total;
@ -185,19 +343,6 @@ class ContentMessages {
dis.dispatch({action: 'upload_progress', upload: upload}); dis.dispatch({action: 'upload_progress', upload: upload});
} }
}).then(function(url) { }).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); return matrixClient.sendMessage(roomId, content);
}, function(err) { }, function(err) {
error = err; error = err;
@ -212,12 +357,12 @@ class ContentMessages {
description: desc description: desc
}); });
} }
}).finally(function() { }).finally(() => {
var inprogressKeys = Object.keys(self.inprogress); const inprogressKeys = Object.keys(this.inprogress);
for (var i = 0; i < self.inprogress.length; ++i) { for (var i = 0; i < this.inprogress.length; ++i) {
var k = inprogressKeys[i]; var k = inprogressKeys[i];
if (self.inprogress[k].promise === upload.promise) { if (this.inprogress[k].promise === upload.promise) {
self.inprogress.splice(k, 1); this.inprogress.splice(k, 1);
break; break;
} }
} }
@ -235,7 +380,7 @@ class ContentMessages {
} }
cancelUpload(promise) { cancelUpload(promise) {
var inprogressKeys = Object.keys(this.inprogress); const inprogressKeys = Object.keys(this.inprogress);
var upload; var upload;
for (var i = 0; i < this.inprogress.length; ++i) { for (var i = 0; i < this.inprogress.length; ++i) {
var k = inprogressKeys[i]; var k = inprogressKeys[i];

View file

@ -48,5 +48,5 @@ module.exports = {
//return pad(date.getHours()) + ':' + pad(date.getMinutes()); //return pad(date.getHours()) + ':' + pad(date.getMinutes());
return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2); return ('00' + date.getHours()).slice(-2) + ':' + ('00' + date.getMinutes()).slice(-2);
} }
} };

View file

@ -136,6 +136,6 @@ module.exports = {
fromUsers: function(users, showInviteButton, inviteFn) { fromUsers: function(users, showInviteButton, inviteFn) {
return users.map(function(u) { return users.map(function(u) {
return new UserEntity(u, showInviteButton, inviteFn); return new UserEntity(u, showInviteButton, inviteFn);
}) });
} }
}; };

View file

@ -91,16 +91,16 @@ var sanitizeHtmlParams = {
], ],
allowedAttributes: { allowedAttributes: {
// custom ones first: // custom ones first:
font: [ 'color' ], // custom to matrix font: ['color'], // custom to matrix
a: [ 'href', 'name', 'target', 'rel' ], // remote target: custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix
// We don't currently allow img itself by default, but this // We don't currently allow img itself by default, but this
// would make sense if we did // 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 // 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 // URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], allowedSchemes: ['http', 'https', 'ftp', 'mailto'],
// DO NOT USE. sanitize-html allows all URL starting with '//' // DO NOT USE. sanitize-html allows all URL starting with '//'
// so this will always allow links to whatever scheme the // so this will always allow links to whatever scheme the

View file

@ -53,5 +53,5 @@ module.exports = {
return Math.floor(heightMulti * fullHeight); return Math.floor(heightMulti * fullHeight);
} }
}, },
} };

View file

@ -19,9 +19,12 @@ import MultiInviter from './utils/MultiInviter';
const emailRegex = /^\S+@\S+\.\S+$/; const emailRegex = /^\S+@\S+\.\S+$/;
// We allow localhost for mxids to avoid confusion
const mxidRegex = /^@\S+:(?:\S+\.\S+|localhost)$/
export function getAddressType(inputText) { export function getAddressType(inputText) {
const isEmailAddress = /^\S+@\S+\.\S+$/.test(inputText); const isEmailAddress = emailRegex.test(inputText);
const isMatrixId = inputText[0] === '@' && inputText.indexOf(":") > 0; const isMatrixId = mxidRegex.test(inputText);
// sanity check the input for user IDs // sanity check the input for user IDs
if (isEmailAddress) { if (isEmailAddress) {
@ -55,29 +58,7 @@ export function inviteToRoom(roomId, addr) {
* @returns Promise * @returns Promise
*/ */
export function inviteMultipleToRoom(roomId, addrs) { export function inviteMultipleToRoom(roomId, addrs) {
this.inviter = new MultiInviter(roomId); const inviter = new MultiInviter(roomId);
return this.inviter.invite(addrs); 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;
}
}

View file

@ -20,6 +20,7 @@ module.exports = {
TAB: 9, TAB: 9,
ENTER: 13, ENTER: 13,
SHIFT: 16, SHIFT: 16,
ESCAPE: 27,
PAGE_UP: 33, PAGE_UP: 33,
PAGE_DOWN: 34, PAGE_DOWN: 34,
END: 35, END: 35,

View file

@ -18,7 +18,7 @@ import q from 'q';
import Matrix from 'matrix-js-sdk'; import Matrix from 'matrix-js-sdk';
import MatrixClientPeg from './MatrixClientPeg'; import MatrixClientPeg from './MatrixClientPeg';
import Notifier from './Notifier' import Notifier from './Notifier';
import UserActivity from './UserActivity'; import UserActivity from './UserActivity';
import Presence from './Presence'; import Presence from './Presence';
import dis from './dispatcher'; import dis from './dispatcher';
@ -140,7 +140,7 @@ function _loginWithToken(queryParams, defaultDeviceDisplayName) {
homeserverUrl: queryParams.homeserver, homeserverUrl: queryParams.homeserver,
identityServerUrl: queryParams.identityServer, identityServerUrl: queryParams.identityServer,
guest: false, guest: false,
}) });
}, (err) => { }, (err) => {
console.error("Failed to log in with login token: " + err + " " + console.error("Failed to log in with login token: " + err + " " +
err.data); err.data);
@ -356,7 +356,7 @@ export function stopMatrixClient() {
Notifier.stop(); Notifier.stop();
UserActivity.stop(); UserActivity.stop();
Presence.stop(); Presence.stop();
DMRoomMap.shared().stop(); if (DMRoomMap.shared()) DMRoomMap.shared().stop();
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.stopClient(); cli.stopClient();

View file

@ -14,20 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import marked from 'marked'; import commonmark from 'commonmark';
// 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. <br /> not <br>)
});
/** /**
* Class that wraps marked, adding the ability to see whether * 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 { export default class Markdown {
constructor(input) { constructor(input) {
const lexer = new marked.Lexer(marked_options); this.input = input;
this.tokens = lexer.lex(input); this.parser = new commonmark.Parser();
} this.renderer = new commonmark.HtmlRenderer({safe: false});
_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);
} }
isPlainText() { isPlainText() {
@ -64,65 +44,81 @@ export default class Markdown {
is_plain = false; is_plain = false;
} }
const dummy_renderer = {}; const dummy_renderer = new commonmark.HtmlRenderer();
for (const k of Object.keys(marked.Renderer.prototype)) { for (const k of Object.keys(commonmark.HtmlRenderer.prototype)) {
dummy_renderer[k] = setNotPlain; dummy_renderer[k] = setNotPlain;
} }
// text and paragraph are just text // text and paragraph are just text
dummy_renderer.text = function(t){return t;} dummy_renderer.text = function(t) { return t; };
dummy_renderer.paragraph = 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: const dummy_parser = new commonmark.Parser();
// this ignores plain URLs that markdown has dummy_renderer.render(dummy_parser.parse(this.input));
// 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());
return is_plain; return is_plain;
} }
toHTML() { toHTML() {
const real_renderer = new marked.Renderer(); const real_paragraph = this.renderer.paragraph;
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);
}
real_renderer.paragraph = (text) => { this.renderer.paragraph = function(node, entering) {
// The tokens at the top level are the 'blocks', so if we // If there is only one top level node, just return the
// have more than one, there are multiple 'paragraphs'.
// If there is only one top level token, just return the
// bare text: it's a single line of text and so should be // bare text: it's a single line of text and so should be
// 'inline', rather than necessarily wrapped in its own // 'inline', rather than unnecessarily wrapped in its own
// p tag. If, however, we have multiple tokens, each gets // p tag. If, however, we have multiple nodes, each gets
// its own p tag to keep them as separate paragraphs. // its own p tag to keep them as separate paragraphs.
if (this.tokens.length == 1) { var par = node;
return text; while (par.parent) {
par = par.parent;
} }
return '<p>' + text + '</p>'; if (par.firstChild != par.lastChild) {
} real_paragraph.call(this, node, entering);
}
};
const real_options = Object.assign({}, marked_options, { var parsed = this.parser.parse(this.input);
renderer: real_renderer, var rendered = this.renderer.render(parsed);
});
const real_parser = new marked.Parser(real_options); this.renderer.paragraph = real_paragraph;
return real_parser.parse(this._copyTokens());
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;
} }
} }

View file

@ -19,44 +19,174 @@ limitations under the License.
var React = require('react'); var React = require('react');
var ReactDOM = require('react-dom'); var ReactDOM = require('react-dom');
import sdk from './index';
module.exports = { const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
DialogContainerId: "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 <Component {...otherProps} />;
} else {
// show a spinner until the component is loaded.
const Spinner = sdk.getComponent("elements.Spinner");
return <Spinner />;
}
},
});
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) { if (!container) {
container = document.createElement("div"); container = document.createElement("div");
container.id = this.DialogContainerId; container.id = DIALOG_CONTAINER_ID;
document.body.appendChild(container); document.body.appendChild(container);
} }
return 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(['<module>'], 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; 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() { var closeDialog = function() {
if (props && props.onFinished) props.onFinished.apply(null, arguments); 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 // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the dialog from a button click! // property set here so you can't close the dialog from a button click!
modal.elem = (
<AsyncWrapper key={modalCount} loader={loader} {...props}
onFinished={closeDialog}/>
);
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 = ( var dialog = (
<div className={"mx_Dialog_wrapper " + className}> <div className={"mx_Dialog_wrapper " + modal.className}>
<div className="mx_Dialog"> <div className="mx_Dialog">
<Element {...props} onFinished={closeDialog}/> {modal.elem}
</div> </div>
<div className="mx_Dialog_background" onClick={ closeDialog.bind(this, false) }></div> <div className="mx_Dialog_background" onClick={ this.closeAll }></div>
</div> </div>
); );
ReactDOM.render(dialog, this.getOrCreateContainer()); ReactDOM.render(dialog, this.getOrCreateContainer());
}
}
return {close: closeDialog}; export default new ModalManager();
},
};

View file

@ -53,7 +53,7 @@ var Notifier = {
if (!msg) return; if (!msg) return;
var title; var title;
if (!ev.sender || room.name == ev.sender.name) { if (!ev.sender || room.name == ev.sender.name) {
title = room.name; title = room.name;
// notificationMessageForEvent includes sender, // notificationMessageForEvent includes sender,
// but we already have the sender here // but we already have the sender here
@ -73,7 +73,7 @@ var Notifier = {
ev.sender, 40, 40, 'crop' ev.sender, 40, 40, 'crop'
) : null; ) : null;
const notif = plaf.displayNotification(title, msg, avatarUrl); const notif = plaf.displayNotification(title, msg, avatarUrl, room);
// if displayNotification returns non-null, the platform supports // if displayNotification returns non-null, the platform supports
// clearing notifications later, so keep track of this. // clearing notifications later, so keep track of this.
@ -88,7 +88,7 @@ var Notifier = {
if (e) { if (e) {
e.load(); e.load();
e.play(); e.play();
}; }
}, },
start: function() { start: function() {

View file

@ -12,7 +12,7 @@ import {
SelectionState, SelectionState,
Entity, Entity,
} from 'draft-js'; } from 'draft-js';
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html'; import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter"; import {SelectionRange} from "./autocomplete/Autocompleter";

View file

@ -26,7 +26,7 @@ function tsOfNewestEvent(room) {
} }
function mostRecentActivityFirst(roomList) { function mostRecentActivityFirst(roomList) {
return roomList.sort(function(a,b) { return roomList.sort(function(a, b) {
return tsOfNewestEvent(b) - tsOfNewestEvent(a); return tsOfNewestEvent(b) - tsOfNewestEvent(a);
}); });
} }

View file

@ -146,7 +146,7 @@ function isRuleForRoom(roomId, rule) {
} }
const cond = rule.conditions[0]; const cond = rule.conditions[0];
if ( if (
cond.kind == 'event_match' && cond.kind == 'event_match' &&
cond.key == 'room_id' && cond.key == 'room_id' &&
cond.pattern == roomId cond.pattern == roomId
) { ) {

View file

@ -37,7 +37,7 @@ export function getOnlyOtherMember(room, me) {
if (joinedMembers.length === 2) { if (joinedMembers.length === 2) {
return joinedMembers.filter(function(m) { return joinedMembers.filter(function(m) {
return m.userId !== me.userId return m.userId !== me.userId;
})[0]; })[0];
} }

80
src/RtsClient.js Normal file
View file

@ -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,
},
}
);
}
}

View file

@ -292,12 +292,15 @@ const onMessage = function(event) {
event.origin = event.originalEvent.origin; 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; let url = SdkConfig.get().integrations_ui_url;
if (url.endsWith("/")) { if (event.origin.length === 0 || !url.startsWith(event.origin)) {
url = url.substr(0, url.length - 1);
}
if (url !== event.origin) {
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise 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) => { }, (err) => {
console.error(err); console.error(err);
sendError(event, "Failed to lookup current room."); sendError(event, "Failed to lookup current room.");
}) });
}; };
module.exports = { module.exports = {

View file

@ -19,6 +19,8 @@ var DEFAULTS = {
integrations_ui_url: "https://scalar.vector.im/", integrations_ui_url: "https://scalar.vector.im/",
// Base URL to the REST interface of the integrations server // Base URL to the REST interface of the integrations server
integrations_rest_url: "https://scalar.vector.im/api", 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 { class SdkConfig {

View file

@ -191,7 +191,7 @@ class Register extends Signup {
} }
} }
if (poll_for_success) { if (poll_for_success) {
return q.delay(5000).then(function() { return q.delay(2000).then(function() {
return self._tryRegister(client, authDict, poll_for_success); return self._tryRegister(client, authDict, poll_for_success);
}); });
} else { } else {
@ -203,7 +203,17 @@ class Register extends Signup {
} else if (error.errcode == 'M_INVALID_USERNAME') { } else if (error.errcode == 'M_INVALID_USERNAME') {
throw new Error("User names may only contain alphanumeric characters, underscores or dots!"); throw new Error("User names may only contain alphanumeric characters, underscores or dots!");
} else if (error.httpStatus >= 400 && error.httpStatus < 500) { } 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) { } else if (error.httpStatus >= 500 && error.httpStatus < 600) {
throw new Error( throw new Error(
`Server error during registration! (${error.httpStatus})` `Server error during registration! (${error.httpStatus})`

View file

@ -52,7 +52,13 @@ DummyStage.TYPE = "m.login.dummy";
class RecaptchaStage extends Stage { class RecaptchaStage extends Stage {
constructor(matrixClient, signupInstance) { constructor(matrixClient, signupInstance) {
super(RecaptchaStage.TYPE, 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. // called when the recaptcha has been completed.
@ -60,16 +66,15 @@ class RecaptchaStage extends Stage {
if (!data || !data.response) { if (!data || !data.response) {
return; return;
} }
this.defer.resolve({ this.authDict.auth.response = data.response;
auth: {
type: 'm.login.recaptcha',
response: data.response,
}
});
} }
complete() { 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"; RecaptchaStage.TYPE = "m.login.recaptcha";

View file

@ -41,7 +41,7 @@ class Command {
} }
getUsage() { 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})))?$/); 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) { if (matches) {
Tinter.tint(matches[1], matches[4]); Tinter.tint(matches[1], matches[4]);
var colorScheme = {} var colorScheme = {};
colorScheme.primary_color = matches[1]; colorScheme.primary_color = matches[1];
if (matches[4]) { if (matches[4]) {
colorScheme.secondary_color = matches[4]; colorScheme.secondary_color = matches[4];
@ -288,7 +288,7 @@ var commands = {
// helpful aliases // helpful aliases
var aliases = { var aliases = {
j: "join" j: "join"
} };
module.exports = { module.exports = {
/** /**
@ -331,9 +331,9 @@ module.exports = {
// Return all the commands plus /me and /markdown which aren't handled like normal commands // Return all the commands plus /me and /markdown which aren't handled like normal commands
var cmds = Object.keys(commands).sort().map(function(cmdKey) { var cmds = Object.keys(commands).sort().map(function(cmdKey) {
return commands[cmdKey]; return commands[cmdKey];
}) });
cmds.push(new Command("me", "<action>", function(){})); cmds.push(new Command("me", "<action>", function() {}));
cmds.push(new Command("markdown", "<on|off>", function(){})); cmds.push(new Command("markdown", "<on|off>", function() {}));
return cmds; return cmds;
} }

View file

@ -112,7 +112,7 @@ class TabComplete {
return; return;
} }
// ES6 destructuring; ignore first element (the complete match) // ES6 destructuring; ignore first element (the complete match)
var [ , boundaryGroup, partialGroup] = res; var [, boundaryGroup, partialGroup] = res;
if (partialGroup.length === 0 && passive) { if (partialGroup.length === 0 && passive) {
return; return;
@ -227,8 +227,20 @@ class TabComplete {
// pressing any key at all (except tab) restarts the automatic tab-complete timer // pressing any key at all (except tab) restarts the automatic tab-complete timer
if (this.opts.autoEnterTabComplete) { if (this.opts.autoEnterTabComplete) {
const cachedText = ev.target.value;
clearTimeout(this.enterTabCompleteTimerId); clearTimeout(this.enterTabCompleteTimerId);
this.enterTabCompleteTimerId = setTimeout(() => { 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) { if (!this.completing) {
this.handleTabPress(true, false); this.handleTabPress(true, false);
} }
@ -242,7 +254,7 @@ class TabComplete {
if (ev.ctrlKey || ev.metaKey || ev.altKey) return; if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
// tab key has been pressed at this point // 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) // prevent the default TAB operation (typically focus shifting)
ev.preventDefault(); ev.preventDefault();
@ -374,6 +386,6 @@ class TabComplete {
this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++; this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++;
} }
} }
}; }
module.exports = TabComplete; module.exports = TabComplete;

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require("react");
var sdk = require("./index"); var sdk = require("./index");
class Entry { class Entry {
@ -90,7 +89,7 @@ CommandEntry.fromCommands = function(commandArray) {
return commandArray.map(function(cmd) { return commandArray.map(function(cmd) {
return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs()); return new CommandEntry(cmd.getCommand(), cmd.getCommandWithArgs());
}); });
} };
class MemberEntry extends Entry { class MemberEntry extends Entry {
constructor(member) { constructor(member) {
@ -119,7 +118,7 @@ MemberEntry.fromMemberList = function(members) {
return members.map(function(m) { return members.map(function(m) {
return new MemberEntry(m); return new MemberEntry(m);
}); });
} };
module.exports.Entry = Entry; module.exports.Entry = Entry;
module.exports.MemberEntry = MemberEntry; module.exports.MemberEntry = MemberEntry;

View file

@ -75,7 +75,6 @@ function textForMemberEvent(ev) {
return targetName + " joined the room."; return targetName + " joined the room.";
} }
} }
return '';
case 'leave': case 'leave':
if (ev.getSender() === ev.getStateKey()) { if (ev.getSender() === ev.getStateKey()) {
if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
@ -203,4 +202,4 @@ module.exports = {
if (!hdlr) return ""; if (!hdlr) return "";
return hdlr(ev); return hdlr(ev);
} }
} };

View file

@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var dis = require("./dispatcher");
var sdk = require("./index");
// FIXME: these vars should be bundled up and attached to // FIXME: these vars should be bundled up and attached to
// module.exports otherwise this will break when included by both // module.exports otherwise this will break when included by both
// react-sdk and apps layered on top. // react-sdk and apps layered on top.
@ -42,6 +39,7 @@ var keyHex = [
"#76CFA6", // Vector Green "#76CFA6", // Vector Green
"#EAF5F0", // Vector Light Green "#EAF5F0", // Vector Light Green
"#D3EFE1", // BottomLeftMenu overlay (20% Vector Green overlaid on 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 // cache of our replacement colours
@ -50,6 +48,7 @@ var colors = [
keyHex[0], keyHex[0],
keyHex[1], keyHex[1],
keyHex[2], keyHex[2],
keyHex[3],
]; ];
var cssFixups = [ var cssFixups = [
@ -150,10 +149,28 @@ function hexToRgb(color) {
function rgbToHex(rgb) { function rgbToHex(rgb) {
var val = (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]; 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 = { 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) { tint: function(primaryColor, secondaryColor, tertiaryColor) {
if (!cached) { if (!cached) {
@ -167,7 +184,7 @@ module.exports = {
} }
if (!secondaryColor) { 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); var rgb = hexToRgb(primaryColor);
rgb[0] = x * rgb[0] + (1 - x) * 255; rgb[0] = x * rgb[0] + (1 - x) * 255;
rgb[1] = x * rgb[1] + (1 - x) * 255; rgb[1] = x * rgb[1] + (1 - x) * 255;
@ -176,7 +193,7 @@ module.exports = {
} }
if (!tertiaryColor) { if (!tertiaryColor) {
var x = 0.19; const x = 0.19;
var rgb1 = hexToRgb(primaryColor); var rgb1 = hexToRgb(primaryColor);
var rgb2 = hexToRgb(secondaryColor); var rgb2 = hexToRgb(secondaryColor);
rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0]; rgb1[0] = x * rgb1[0] + (1 - x) * rgb2[0];
@ -192,7 +209,9 @@ module.exports = {
return; return;
} }
colors = [primaryColor, secondaryColor, tertiaryColor]; colors[0] = primaryColor;
colors[1] = secondaryColor;
colors[2] = tertiaryColor;
if (DEBUG) console.log("Tinter.tint"); if (DEBUG) console.log("Tinter.tint");
@ -201,12 +220,22 @@ module.exports = {
// tell all the SVGs to go fix themselves up // tell all the SVGs to go fix themselves up
// we don't do this as a dispatch otherwise it will visually lag // we don't do this as a dispatch otherwise it will visually lag
var TintableSvg = sdk.getComponent("elements.TintableSvg"); tintables.forEach(function(tintable) {
if (TintableSvg.mounts) { tintable();
Object.keys(TintableSvg.mounts).forEach((id) => { });
TintableSvg.mounts[id].tint(); },
});
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 // 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]); svgFixup.node.setAttribute(svgFixup.attr, colors[svgFixup.index]);
} }
if (DEBUG) console.log("applySvgFixups end"); if (DEBUG) console.log("applySvgFixups end");
}, }
}; };

View file

@ -30,16 +30,6 @@ module.exports = {
id: 'rich_text_editor', id: 'rich_text_editor',
default: false, default: false,
}, },
{
name: 'End-to-End Encryption',
id: 'e2e_encryption',
default: true,
},
{
name: 'Integration Management',
id: 'integration_management',
default: true,
},
], ],
loadProfileInfo: function() { loadProfileInfo: function() {

View file

@ -76,7 +76,7 @@ module.exports = React.createClass({
var startStyles = self.props.startStyles; var startStyles = self.props.startStyles;
if (startStyles.length > 0) { if (startStyles.length > 0) {
var startStyle = startStyles[0] var startStyle = startStyles[0];
newProps.style = startStyle; newProps.style = startStyle;
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); // console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
} }
@ -105,7 +105,7 @@ module.exports = React.createClass({
) { ) {
var startStyles = this.props.startStyles; var startStyles = this.props.startStyles;
var transitionOpts = this.props.enterTransitionOpts; 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 // start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc. // to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) { for (var i = 1; i < startStyles.length; ++i) {
@ -145,7 +145,7 @@ module.exports = React.createClass({
// and the FAQ entry, "Preventing memory leaks when // and the FAQ entry, "Preventing memory leaks when
// creating/destroying large numbers of elements" // creating/destroying large numbers of elements"
// (https://github.com/julianshapiro/velocity/issues/47) // (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); Velocity.Utilities.removeData(domNode);
} }
this.nodes[k] = node; this.nodes[k] = node;

View file

@ -6,10 +6,12 @@ function bounce( p ) {
var pow2, var pow2,
bounce = 4; 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 ); return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 );
} }
Velocity.Easings.easeOutBounce = function(p) { Velocity.Easings.easeOutBounce = function(p) {
return 1 - bounce(1 - p); return 1 - bounce(1 - p);
} };

View file

@ -32,18 +32,25 @@ module.exports = {
return whoIsTyping; return whoIsTyping;
}, },
whoIsTypingString: function(room) { whoIsTypingString: function(room, limit) {
var whoIsTyping = this.usersTypingApartFromMe(room); const whoIsTyping = this.usersTypingApartFromMe(room);
const othersCount = limit === undefined ?
0 : Math.max(whoIsTyping.length - limit, 0);
if (whoIsTyping.length == 0) { if (whoIsTyping.length == 0) {
return null; return '';
} else if (whoIsTyping.length == 1) { } else if (whoIsTyping.length == 1) {
return whoIsTyping[0].name + ' is typing'; 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 { } else {
var names = whoIsTyping.map(function(m) { const lastPerson = names.pop();
return m.name;
});
var lastPerson = names.shift();
return names.join(', ') + ' and ' + lastPerson + ' are typing'; return names.join(', ') + ' and ' + lastPerson + ' are typing';
} }
} }
} };

View file

@ -83,7 +83,7 @@ module.exports = React.createClass({
var verificationStatus = (<b>NOT verified</b>); var verificationStatus = (<b>NOT verified</b>);
if (device.isBlocked()) { if (device.isBlocked()) {
verificationStatus = (<b>Blocked</b>); verificationStatus = (<b>Blacklisted</b>);
} else if (device.isVerified()) { } else if (device.isVerified()) {
verificationStatus = "verified"; verificationStatus = "verified";
} }

View file

@ -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 (
<BaseDialog className='mx_exportE2eKeysDialog'
onFinished={this.props.onFinished}
title="Export room keys"
>
<form onSubmit={this._onPassphraseFormSubmit}>
<div className="mx_Dialog_content">
<p>
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.
</p>
<p>
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.
</p>
<div className='error'>
{this.state.errStr}
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase1'>
Enter passphrase
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase1' id='passphrase1'
autoFocus={true} size='64' type='password'
disabled={disableForm}
/>
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase2'>
Confirm passphrase
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase2' id='passphrase2'
size='64' type='password'
disabled={disableForm}
/>
</div>
</div>
</div>
</div>
<div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value='Export'
disabled={disableForm}
/>
<button onClick={this._onCancelClick} disabled={disableForm}>
Cancel
</button>
</div>
</form>
</BaseDialog>
);
},
});

View file

@ -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 (
<BaseDialog className='mx_importE2eKeysDialog'
onFinished={this.props.onFinished}
title="Import room keys"
>
<form onSubmit={this._onFormSubmit}>
<div className="mx_Dialog_content">
<p>
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.
</p>
<p>
The export file will be protected with a passphrase.
You should enter the passphrase here, to decrypt the
file.
</p>
<div className='error'>
{this.state.errStr}
</div>
<div className='mx_E2eKeysDialog_inputTable'>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='importFile'>
File to import
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='file' id='importFile' type='file'
autoFocus={true}
onChange={this._onFormChange}
disabled={disableForm} />
</div>
</div>
<div className='mx_E2eKeysDialog_inputRow'>
<div className='mx_E2eKeysDialog_inputLabel'>
<label htmlFor='passphrase'>
Enter passphrase
</label>
</div>
<div className='mx_E2eKeysDialog_inputCell'>
<input ref='passphrase' id='passphrase'
size='64' type='password'
onChange={this._onFormChange}
disabled={disableForm}/>
</div>
</div>
</div>
</div>
<div className='mx_Dialog_buttons'>
<input className='mx_Dialog_primary' type='submit' value='Import'
disabled={!this.state.enableSubmit || disableForm}
/>
<button onClick={this._onCancelClick} disabled={disableForm}>
Cancel
</button>
</div>
</form>
</BaseDialog>
);
},
});

View file

@ -83,7 +83,7 @@ export default class CommandProvider extends AutocompleteProvider {
static getInstance(): CommandProvider { static getInstance(): CommandProvider {
if (instance == null) if (instance == null)
instance = new CommandProvider(); {instance = new CommandProvider();}
return instance; return instance;
} }

View file

@ -44,7 +44,7 @@ export default class EmojiProvider extends AutocompleteProvider {
static getInstance() { static getInstance() {
if (instance == null) if (instance == null)
instance = new EmojiProvider(); {instance = new EmojiProvider();}
return instance; return instance;
} }

View file

@ -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); 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'; 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); 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'; import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInviteDialog';
views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog);
import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog'; import views$dialogs$DeactivateAccountDialog from './components/views/dialogs/DeactivateAccountDialog';
views$dialogs$DeactivateAccountDialog && (module.exports.components['views.dialogs.DeactivateAccountDialog'] = 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'; import views$dialogs$ErrorDialog from './components/views/dialogs/ErrorDialog';
views$dialogs$ErrorDialog && (module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog); views$dialogs$ErrorDialog && (module.exports.components['views.dialogs.ErrorDialog'] = views$dialogs$ErrorDialog);
import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog'; import views$dialogs$InteractiveAuthDialog from './components/views/dialogs/InteractiveAuthDialog';
views$dialogs$InteractiveAuthDialog && (module.exports.components['views.dialogs.InteractiveAuthDialog'] = 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'; import views$dialogs$NeedToRegisterDialog from './components/views/dialogs/NeedToRegisterDialog';
views$dialogs$NeedToRegisterDialog && (module.exports.components['views.dialogs.NeedToRegisterDialog'] = views$dialogs$NeedToRegisterDialog); views$dialogs$NeedToRegisterDialog && (module.exports.components['views.dialogs.NeedToRegisterDialog'] = views$dialogs$NeedToRegisterDialog);
import views$dialogs$QuestionDialog from './components/views/dialogs/QuestionDialog'; 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); views$dialogs$SetDisplayNameDialog && (module.exports.components['views.dialogs.SetDisplayNameDialog'] = views$dialogs$SetDisplayNameDialog);
import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputDialog'; import views$dialogs$TextInputDialog from './components/views/dialogs/TextInputDialog';
views$dialogs$TextInputDialog && (module.exports.components['views.dialogs.TextInputDialog'] = 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'; import views$elements$AddressSelector from './components/views/elements/AddressSelector';
views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector); views$elements$AddressSelector && (module.exports.components['views.elements.AddressSelector'] = views$elements$AddressSelector);
import views$elements$AddressTile from './components/views/elements/AddressTile'; import views$elements$AddressTile from './components/views/elements/AddressTile';

View file

@ -47,7 +47,7 @@ module.exports = {
return container; return container;
}, },
createMenu: function (Element, props) { createMenu: function(Element, props) {
var self = this; var self = this;
var closeMenu = function() { var closeMenu = function() {
@ -67,7 +67,7 @@ module.exports = {
chevronOffset.top = props.chevronOffset; 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 = ""; var chevronCSS = "";
if (props.menuColour) { if (props.menuColour) {
chevronCSS = ` chevronCSS = `
@ -78,15 +78,15 @@ module.exports = {
.mx_ContextualMenu_chevron_right:after { .mx_ContextualMenu_chevron_right:after {
border-left-color: ${props.menuColour}; border-left-color: ${props.menuColour};
} }
` `;
} }
var chevron = null; var chevron = null;
if (props.left) { if (props.left) {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div> chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div>;
position.left = props.left; position.left = props.left;
} else { } else {
chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div> chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div>;
position.right = props.right; position.right = props.right;
} }

View file

@ -118,7 +118,7 @@ module.exports = React.createClass({
var self = this; var self = this;
deferred.then(function (resp) { deferred.then(function(resp) {
self.setState({ self.setState({
phase: self.phases.CREATED, phase: self.phases.CREATED,
}); });
@ -210,7 +210,7 @@ module.exports = React.createClass({
onAliasChanged: function(alias) { onAliasChanged: function(alias) {
this.setState({ this.setState({
alias: alias alias: alias
}) });
}, },
onEncryptChanged: function(ev) { onEncryptChanged: function(ev) {

View file

@ -35,7 +35,7 @@ var FilePanel = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
timelineSet: null, timelineSet: null,
} };
}, },
componentWillMount: function() { componentWillMount: function() {

View file

@ -21,6 +21,7 @@ import KeyCode from '../../KeyCode';
import Notifier from '../../Notifier'; import Notifier from '../../Notifier';
import PageTypes from '../../PageTypes'; import PageTypes from '../../PageTypes';
import sdk from '../../index'; import sdk from '../../index';
import dis from '../../dispatcher';
/** /**
* This is what our MatrixChat shows when we are logged in. The precise view is * 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} collapsedRhs={this.props.collapse_rhs}
ConferenceHandler={this.props.ConferenceHandler} ConferenceHandler={this.props.ConferenceHandler}
scrollStateMap={this._scrollStateMap} scrollStateMap={this._scrollStateMap}
/> />;
if (!this.props.collapse_rhs) right_panel = <RightPanel roomId={this.props.currentRoomId} opacity={this.props.sideOpacity} /> if (!this.props.collapse_rhs) right_panel = <RightPanel roomId={this.props.currentRoomId} opacity={this.props.sideOpacity} />;
break; break;
case PageTypes.UserSettings: case PageTypes.UserSettings:
@ -171,24 +172,25 @@ export default React.createClass({
brand={this.props.config.brand} brand={this.props.config.brand}
collapsedRhs={this.props.collapse_rhs} collapsedRhs={this.props.collapse_rhs}
enableLabs={this.props.config.enableLabs} enableLabs={this.props.config.enableLabs}
/> referralBaseUrl={this.props.config.referralBaseUrl}
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/> />;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break; break;
case PageTypes.CreateRoom: case PageTypes.CreateRoom:
page_element = <CreateRoom page_element = <CreateRoom
onRoomCreated={this.props.onRoomCreated} onRoomCreated={this.props.onRoomCreated}
collapsedRhs={this.props.collapse_rhs} collapsedRhs={this.props.collapse_rhs}
/> />;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/> if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break; break;
case PageTypes.RoomDirectory: case PageTypes.RoomDirectory:
page_element = <RoomDirectory page_element = <RoomDirectory
collapsedRhs={this.props.collapse_rhs} collapsedRhs={this.props.collapse_rhs}
config={this.props.config.roomDirectory} config={this.props.config.roomDirectory}
/> />;
if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/> if (!this.props.collapse_rhs) right_panel = <RightPanel opacity={this.props.sideOpacity}/>;
break; break;
case PageTypes.HomePage: case PageTypes.HomePage:
@ -201,7 +203,7 @@ export default React.createClass({
case PageTypes.UserView: case PageTypes.UserView:
page_element = null; // deliberately null for now page_element = null; // deliberately null for now
right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.sideOpacity} /> right_panel = <RightPanel userId={this.props.viewUserId} opacity={this.props.sideOpacity} />;
break; break;
} }

View file

@ -66,15 +66,28 @@ module.exports = React.createClass({
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: React.PropTypes.string,
}, },
childContextTypes: {
appConfig: React.PropTypes.object,
},
AuxPanel: { AuxPanel: {
RoomSettings: "room_settings", RoomSettings: "room_settings",
}, },
getChildContext: function() {
return {
appConfig: this.props.config,
};
},
getInitialState: function() { getInitialState: function() {
var s = { var s = {
loading: true, loading: true,
screen: undefined, 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 // If we are viewing a room by alias, this contains the alias
currentRoomAlias: null, currentRoomAlias: null,
@ -235,8 +248,6 @@ module.exports = React.createClass({
setStateForNewScreen: function(state) { setStateForNewScreen: function(state) {
const newState = { const newState = {
screen: undefined, screen: undefined,
currentRoomAlias: null,
currentRoomId: null,
viewUserId: null, viewUserId: null,
logged_in: false, logged_in: false,
ready: false, ready: false,
@ -449,6 +460,9 @@ module.exports = React.createClass({
middleOpacity: payload.middleOpacity, middleOpacity: payload.middleOpacity,
}); });
break; break;
case 'set_theme':
this._onSetTheme(payload.value);
break;
case 'on_logged_in': case 'on_logged_in':
this._onLoggedIn(); this._onLoggedIn();
break; break;
@ -579,6 +593,50 @@ module.exports = React.createClass({
this.setState({loading: false}); 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 * Called when a new logged in session has started
*/ */
@ -601,6 +659,9 @@ module.exports = React.createClass({
ready: false, ready: false,
collapse_lhs: false, collapse_lhs: false,
collapse_rhs: false, collapse_rhs: false,
currentRoomAlias: null,
currentRoomId: null,
page_type: PageTypes.RoomDirectory,
}); });
}, },
@ -687,6 +748,16 @@ module.exports = React.createClass({
action: 'logout' 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) { onFocus: function(ev) {
@ -983,7 +1054,7 @@ module.exports = React.createClass({
{...this.props} {...this.props}
{...this.state} {...this.state}
/> />
) );
} else if (this.state.logged_in) { } else if (this.state.logged_in) {
// we think we are logged in, but are still waiting for the /sync to complete // we think we are logged in, but are still waiting for the /sync to complete
var Spinner = sdk.getComponent('elements.Spinner'); var Spinner = sdk.getComponent('elements.Spinner');
@ -1002,11 +1073,13 @@ module.exports = React.createClass({
sessionId={this.state.register_session_id} sessionId={this.state.register_session_id}
idSid={this.state.register_id_sid} idSid={this.state.register_id_sid}
email={this.props.startingFragmentQueryParams.email} email={this.props.startingFragmentQueryParams.email}
referrer={this.props.startingFragmentQueryParams.referrer}
username={this.state.upgradeUsername} username={this.state.upgradeUsername}
guestAccessToken={this.state.guestAccessToken} guestAccessToken={this.state.guestAccessToken}
defaultHsUrl={this.getDefaultHsUrl()} defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()} defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand} brand={this.props.config.brand}
teamServerConfig={this.props.config.teamServerConfig}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
registrationUrl={this.props.registrationUrl} registrationUrl={this.props.registrationUrl}
@ -1025,6 +1098,7 @@ module.exports = React.createClass({
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick} onComplete={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onLoginClick={this.onLoginClick} /> onLoginClick={this.onLoginClick} />
); );
} else { } else {

View file

@ -19,7 +19,9 @@ var ReactDOM = require("react-dom");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var sdk = require('../../index'); 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. /* (almost) stateless UI component which builds the event tiles in the room timeline.
*/ */
@ -229,6 +231,7 @@ module.exports = React.createClass({
_getEventTiles: function() { _getEventTiles: function() {
var EventTile = sdk.getComponent('rooms.EventTile'); var EventTile = sdk.getComponent('rooms.EventTile');
var DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
this.eventNodes = {}; this.eventNodes = {};
@ -278,8 +281,7 @@ module.exports = React.createClass({
var isMembershipChange = (e) => var isMembershipChange = (e) =>
e.getType() === 'm.room.member' e.getType() === 'm.room.member'
&& ['join', 'leave'].indexOf(e.event.content.membership) !== -1 && (!e.getPrevContent() || e.getContent().membership !== e.getPrevContent().membership);
&& (!e.event.prev_content || e.event.content.membership !== e.event.prev_content.membership);
for (i = 0; i < this.props.events.length; i++) { for (i = 0; i < this.props.events.length; i++) {
var mxEv = this.props.events[i]; var mxEv = this.props.events[i];
@ -292,37 +294,63 @@ module.exports = React.createClass({
var last = (i == lastShownEventIndex); var last = (i == lastShownEventIndex);
// Wrap consecutive member events in a ListSummary // Wrap consecutive member events in a ListSummary, ignore if redacted
if (isMembershipChange(mxEv)) { if (isMembershipChange(mxEv) && EventTile.haveTileForEvent(mxEv)) {
let summarisedEvents = [mxEv]; let ts1 = mxEv.getTs();
i++; // Ensure that the key of the MemberEventListSummary does not change with new
for (;i < this.props.events.length; i++) { // member events. This will prevent it from being re-created unnecessarily, and
let collapsedMxEv = this.props.events[i]; // 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)) { if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) {
i--; let dateSeparator = <li key={ts1+'~'}><DateSeparator key={ts1+'~'} ts={ts1}/></li>;
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; break;
} }
summarisedEvents.push(collapsedMxEv); summarisedEvents.push(collapsedMxEv);
} }
// At this point, i = this.props.events.length OR i = the index of the last // At this point, i = the index of the last event in the summary sequence
// MembershipChange in a sequence of MembershipChanges
let eventTiles = summarisedEvents.map( let eventTiles = summarisedEvents.map(
(e) => { (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; prevEvent = e;
return ret; return ret;
} }
).reduce((a,b) => a.concat(b)); ).reduce((a, b) => a.concat(b));
if (eventTiles.length === 0) { if (eventTiles.length === 0) {
eventTiles = null; eventTiles = null;
} }
ret.push( ret.push(
<MemberEventListSummary key={mxEv.getId()} events={summarisedEvents}> <MemberEventListSummary
{eventTiles} key={key}
events={summarisedEvents}
data-scroll-token={eventId}>
{eventTiles}
</MemberEventListSummary> </MemberEventListSummary>
); );
continue; continue;
@ -405,12 +433,14 @@ module.exports = React.createClass({
// local echoes have a fake date, which could even be yesterday. Treat them // local echoes have a fake date, which could even be yesterday. Treat them
// as 'today' for the date separators. // as 'today' for the date separators.
var ts1 = mxEv.getTs(); var ts1 = mxEv.getTs();
var eventDate = mxEv.getDate();
if (mxEv.status) { if (mxEv.status) {
ts1 = new Date(); eventDate = new Date();
ts1 = eventDate.getTime();
} }
// do we need a date separator since the last event? // do we need a date separator since the last event?
if (this._wantsDateSeparator(prevEvent, ts1)) { if (this._wantsDateSeparator(prevEvent, eventDate)) {
var dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>; var dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1}/></li>;
ret.push(dateSeparator); ret.push(dateSeparator);
continuation = false; continuation = false;
@ -447,38 +477,48 @@ module.exports = React.createClass({
return ret; return ret;
}, },
_wantsDateSeparator: function(prevEvent, nextEventTs) { _wantsDateSeparator: function(prevEvent, nextEventDate) {
if (prevEvent == null) { if (prevEvent == null) {
// first event in the panel: depends if we could back-paginate from // first event in the panel: depends if we could back-paginate from
// here. // here.
return !this.props.suppressFirstDateSeparator; return !this.props.suppressFirstDateSeparator;
} }
// Return early for events that are > 24h apart
return (new Date(prevEvent.getTs()).toDateString() if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) {
!== new Date(nextEventTs).toDateString()); return true;
},
// 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 room.getReceiptsForEvent(event).filter(function(r) { // Compare weekdays
return r.type === "m.read" && r.userId != myUserId; return prevEvent.getDate().getDay() !== nextEventDate.getDay();
}).sort(function(r1, r2) { },
return r2.data.ts - r1.data.ts;
}).map(function(r) { // get a list of read receipts that should be shown next to this event
return room.getMember(r.userId); // Receipts are objects which have a 'roomMember' and 'ts'.
}).filter(function(m) { _getReadReceiptsForEvent: function(event) {
// check that the user is a known room member const myUserId = MatrixClientPeg.get().credentials.userId;
return m;
// 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 } onScroll={ this.props.onScroll }
onResize={ this.onResize } onResize={ this.onResize }
onFillRequest={ this.props.onFillRequest } onFillRequest={ this.props.onFillRequest }
onUnfillRequest={ this.props.onUnfillRequest }
style={ style } style={ style }
stickyBottom={ this.props.stickyBottom }> stickyBottom={ this.props.stickyBottom }>
{topSpinner} {topSpinner}

View file

@ -19,6 +19,12 @@ var sdk = require('../../index');
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var WhoIsTyping = require("../../WhoIsTyping"); var WhoIsTyping = require("../../WhoIsTyping");
var MatrixClientPeg = require("../../MatrixClientPeg"); 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({ module.exports = React.createClass({
displayName: 'RoomStatusBar', displayName: 'RoomStatusBar',
@ -45,6 +51,10 @@ module.exports = React.createClass({
// more interesting) // more interesting)
hasActiveCall: React.PropTypes.bool, 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 // callback for when the user clicks on the 'resend all' button in the
// 'unsent messages' bar // 'unsent messages' bar
onResendAllClick: React.PropTypes.func, 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 // status bar. This is used to trigger a re-layout in the parent
// component. // component.
onResize: React.PropTypes.func, 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() { getInitialState: function() {
return { return {
syncState: MatrixClientPeg.get().getSyncState(), 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)) { if(this.props.onResize && this._checkForResize(prevProps, prevState)) {
this.props.onResize(); 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() { componentWillUnmount: function() {
@ -100,39 +138,35 @@ module.exports = React.createClass({
onRoomMemberTyping: function(ev, member) { onRoomMemberTyping: function(ev, member) {
this.setState({ 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 // determine if we need to call onResize
_checkForResize: function(prevProps, prevState) { _checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar. We // figure out the old height and the new height of the status bar.
// don't need the actual height - just whether it is likely to have return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state);
// 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;
}, },
// return suitable content for the image on the left of the status bar. // return suitable content for the image on the left of the status bar.
@ -173,10 +207,8 @@ module.exports = React.createClass({
if (wantPlaceholder) { if (wantPlaceholder) {
return ( return (
<div className="mx_RoomStatusBar_placeholderIndicator"> <div className="mx_RoomStatusBar_typingIndicatorAvatars">
<span>.</span> {this._renderTypingIndicatorAvatars(this.props.whoIsTypingLimit)}
<span>.</span>
<span>.</span>
</div> </div>
); );
} }
@ -184,6 +216,36 @@ module.exports = React.createClass({
return null; 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 (
<MemberAvatar
key={u.userId}
member={u}
width={24}
height={24}
resizeMethod="crop"
defaultToInitialLetter={showInitial}
/>
);
});
if (othersCount > 0) {
avatars.push(
<span className="mx_RoomStatusBar_typingIndicatorRemaining">
+{othersCount}
</span>
);
}
return avatars;
},
// return suitable content for the main (text) part of the status bar. // return suitable content for the main (text) part of the status bar.
_getContent: function() { _getContent: function() {

View file

@ -48,7 +48,7 @@ if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); var debuglog = console.log.bind(console);
} else { } else {
var debuglog = function () {}; var debuglog = function() {};
} }
module.exports = React.createClass({ module.exports = React.createClass({
@ -146,7 +146,9 @@ module.exports = React.createClass({
showTopUnreadMessagesBar: false, showTopUnreadMessagesBar: false,
auxPanelMaxHeight: undefined, auxPanelMaxHeight: undefined,
}
statusBarVisible: false,
};
}, },
componentWillMount: function() { componentWillMount: function() {
@ -674,8 +676,9 @@ module.exports = React.createClass({
}, },
onSearchResultsFillRequest: function(backwards) { onSearchResultsFillRequest: function(backwards) {
if (!backwards) if (!backwards) {
return q(false); return q(false);
}
if (this.state.searchResults.next_batch) { if (this.state.searchResults.next_batch) {
debuglog("requesting more search results"); debuglog("requesting more search results");
@ -719,15 +722,11 @@ module.exports = React.createClass({
if (!result.displayname) { if (!result.displayname) {
var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog'); var SetDisplayNameDialog = sdk.getComponent('views.dialogs.SetDisplayNameDialog');
var dialog_defer = q.defer(); var dialog_defer = q.defer();
var dialog_ref;
Modal.createDialog(SetDisplayNameDialog, { Modal.createDialog(SetDisplayNameDialog, {
currentDisplayName: result.displayname, currentDisplayName: result.displayname,
ref: (r) => { onFinished: (submitted, newDisplayName) => {
dialog_ref = r;
},
onFinished: (submitted) => {
if (submitted) { if (submitted) {
cli.setDisplayName(dialog_ref.getValue()).done(() => { cli.setDisplayName(newDisplayName).done(() => {
dialog_defer.resolve(); dialog_defer.resolve();
}); });
} }
@ -758,7 +757,7 @@ module.exports = React.createClass({
}).then(() => { }).then(() => {
var sign_url = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined; var sign_url = this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : undefined;
return MatrixClientPeg.get().joinRoom(this.props.roomAddress, return MatrixClientPeg.get().joinRoom(this.props.roomAddress,
{ inviteSignUrl: sign_url } ) { inviteSignUrl: sign_url } );
}).then(function(resp) { }).then(function(resp) {
var roomId = resp.roomId; var roomId = resp.roomId;
@ -810,11 +809,6 @@ module.exports = React.createClass({
} else { } else {
var msg = error.message ? error.message : JSON.stringify(error); var msg = error.message ? error.message : JSON.stringify(error);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); 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, { Modal.createDialog(ErrorDialog, {
title: "Failed to join room", title: "Failed to join room",
description: msg description: msg
@ -967,7 +961,7 @@ module.exports = React.createClass({
// For overlapping highlights, // For overlapping highlights,
// favour longer (more specific) terms first // favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) { highlights = highlights.sort(function(a, b) {
return b.length - a.length }); return b.length - a.length; });
self.setState({ self.setState({
searchHighlights: highlights, searchHighlights: highlights,
@ -1030,7 +1024,7 @@ module.exports = React.createClass({
if (scrollPanel) { if (scrollPanel) {
scrollPanel.checkScroll(); scrollPanel.checkScroll();
} }
} };
var lastRoomId; var lastRoomId;
@ -1095,7 +1089,7 @@ module.exports = React.createClass({
} }
this.refs.room_settings.save().then((results) => { 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); console.log("Settings saved with %s errors", fails.length);
if (fails.length) { if (fails.length) {
fails.forEach(function(result) { fails.forEach(function(result) {
@ -1104,7 +1098,7 @@ module.exports = React.createClass({
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Failed to save settings", 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 // still editing room settings
} }
@ -1188,7 +1182,7 @@ module.exports = React.createClass({
this.setState({ searching: true }); this.setState({ searching: true });
}, },
onCancelSearchClick: function () { onCancelSearchClick: function() {
this.setState({ this.setState({
searching: false, searching: false,
searchResults: null, searchResults: null,
@ -1213,8 +1207,9 @@ module.exports = React.createClass({
// decide whether or not the top 'unread messages' bar should be shown // decide whether or not the top 'unread messages' bar should be shown
_updateTopUnreadMessagesBar: function() { _updateTopUnreadMessagesBar: function() {
if (!this.refs.messagePanel) if (!this.refs.messagePanel) {
return; return;
}
var pos = this.refs.messagePanel.getReadMarkerPosition(); var pos = this.refs.messagePanel.getReadMarkerPosition();
@ -1336,6 +1331,20 @@ module.exports = React.createClass({
// no longer anything to do here // 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) { showSettings: function(show) {
// XXX: this is a bit naughty; we should be doing this via props // XXX: this is a bit naughty; we should be doing this via props
if (show) { if (show) {
@ -1500,13 +1509,14 @@ module.exports = React.createClass({
}); });
var statusBar; var statusBar;
let isStatusAreaExpanded = true;
if (ContentMessages.getCurrentUploads().length > 0) { if (ContentMessages.getCurrentUploads().length > 0) {
var UploadBar = sdk.getComponent('structures.UploadBar'); var UploadBar = sdk.getComponent('structures.UploadBar');
statusBar = <UploadBar room={this.state.room} /> statusBar = <UploadBar room={this.state.room} />;
} else if (!this.state.searchResults) { } else if (!this.state.searchResults) {
var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
isStatusAreaExpanded = this.state.statusBarVisible;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
tabComplete={this.tabComplete} tabComplete={this.tabComplete}
@ -1518,7 +1528,10 @@ module.exports = React.createClass({
onCancelAllClick={this.onCancelAllClick} onCancelAllClick={this.onCancelAllClick}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
onResize={this.onChildResize} onResize={this.onChildResize}
/> onVisible={this.onStatusBarVisible}
onHidden={this.onStatusBarHidden}
whoIsTypingLimit={2}
/>;
} }
var aux = null; var aux = null;
@ -1574,7 +1587,7 @@ module.exports = React.createClass({
messageComposer = messageComposer =
<MessageComposer <MessageComposer
room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile} room={this.state.room} onResize={this.onChildResize} uploadFile={this.uploadFile}
callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/> callState={this.state.callState} tabComplete={this.tabComplete} opacity={ this.props.opacity }/>;
} }
// TODO: Why aren't we storing the term/scope/count in this format // TODO: Why aren't we storing the term/scope/count in this format
@ -1602,14 +1615,14 @@ module.exports = React.createClass({
<img src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"} <img src={call.isLocalVideoMuted() ? "img/video-unmute.svg" : "img/video-mute.svg"}
alt={call.isLocalVideoMuted() ? "Click to unmute video" : "Click to mute video"} alt={call.isLocalVideoMuted() ? "Click to unmute video" : "Click to mute video"}
width="31" height="27"/> width="31" height="27"/>
</div> </div>;
} }
voiceMuteButton = voiceMuteButton =
<div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}> <div className="mx_RoomView_voipButton" onClick={this.onMuteAudioClick}>
<img src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"} <img src={call.isMicrophoneMuted() ? "img/voice-unmute.svg" : "img/voice-mute.svg"}
alt={call.isMicrophoneMuted() ? "Click to unmute audio" : "Click to mute audio"} alt={call.isMicrophoneMuted() ? "Click to unmute audio" : "Click to mute audio"}
width="21" height="26"/> width="21" height="26"/>
</div> </div>;
// wrap the existing status bar into a 'callStatusBar' which adds more knobs. // wrap the existing status bar into a 'callStatusBar' which adds more knobs.
statusBar = statusBar =
@ -1619,7 +1632,7 @@ module.exports = React.createClass({
{ zoomButton } { zoomButton }
{ statusBar } { statusBar }
<TintableSvg className="mx_RoomView_voipChevron" src="img/voip-chevron.svg" width="22" height="17"/> <TintableSvg className="mx_RoomView_voipChevron" src="img/voip-chevron.svg" width="22" height="17"/>
</div> </div>;
} }
// if we have search results, we keep the messagepanel (so that it preserves its // if we have search results, we keep the messagepanel (so that it preserves its
@ -1672,6 +1685,10 @@ module.exports = React.createClass({
</div> </div>
); );
} }
let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable";
if (isStatusAreaExpanded) {
statusBarAreaClass += " mx_RoomView_statusArea_expanded";
}
return ( return (
<div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView"> <div className={ "mx_RoomView" + (inCall ? " mx_RoomView_inCall" : "") } ref="roomView">
@ -1694,7 +1711,7 @@ module.exports = React.createClass({
{ topUnreadMessagesBar } { topUnreadMessagesBar }
{ messagePanel } { messagePanel }
{ searchResultsPanel } { searchResultsPanel }
<div className="mx_RoomView_statusArea mx_fadable" style={{ opacity: this.props.opacity }}> <div className={statusBarAreaClass} style={{opacity: this.props.opacity}}>
<div className="mx_RoomView_statusAreaBox"> <div className="mx_RoomView_statusAreaBox">
<div className="mx_RoomView_statusAreaBox_line"></div> <div className="mx_RoomView_statusAreaBox_line"></div>
{ statusBar } { statusBar }

View file

@ -23,11 +23,18 @@ var KeyCode = require('../../KeyCode');
var DEBUG_SCROLL = false; var DEBUG_SCROLL = false;
// var DEBUG_SCROLL = true; // 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) { if (DEBUG_SCROLL) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); var debuglog = console.log.bind(console);
} else { } else {
var debuglog = function () {}; var debuglog = function() {};
} }
/* This component implements an intelligent scrolling list. /* This component implements an intelligent scrolling list.
@ -101,6 +108,17 @@ module.exports = React.createClass({
*/ */
onFillRequest: React.PropTypes.func, 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: a callback which is called whenever any scroll happens.
*/ */
onScroll: React.PropTypes.func, onScroll: React.PropTypes.func,
@ -124,6 +142,7 @@ module.exports = React.createClass({
stickyBottom: true, stickyBottom: true,
startAtBottom: true, startAtBottom: true,
onFillRequest: function(backwards) { return q(false); }, onFillRequest: function(backwards) { return q(false); },
onUnfillRequest: function(backwards, scrollToken) {},
onScroll: function() {}, onScroll: function() {},
}; };
}, },
@ -226,6 +245,46 @@ module.exports = React.createClass({
return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; 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. // check the scroll state and send out backfill requests if necessary.
checkFillState: function() { checkFillState: function() {
if (this.unmounted) { 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. // check if there is already a pending fill request. If not, set one off.
_maybeFill: function(backwards) { _maybeFill: function(backwards) {
var dir = backwards ? 'b' : 'f'; var dir = backwards ? 'b' : 'f';
@ -285,7 +393,7 @@ module.exports = React.createClass({
this._pendingFillRequests[dir] = true; this._pendingFillRequests[dir] = true;
var fillPromise; var fillPromise;
try { try {
fillPromise = this.props.onFillRequest(backwards); fillPromise = this.props.onFillRequest(backwards);
} catch (e) { } catch (e) {
this._pendingFillRequests[dir] = false; this._pendingFillRequests[dir] = false;
throw e; throw e;
@ -294,6 +402,12 @@ module.exports = React.createClass({
q.finally(fillPromise, () => { q.finally(fillPromise, () => {
this._pendingFillRequests[dir] = false; this._pendingFillRequests[dir] = false;
}).then((hasMoreResults) => { }).then((hasMoreResults) => {
if (this.unmounted) {
return;
}
// Unpaginate once filling is complete
this._checkUnfillState(!backwards);
debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults); debuglog("ScrollPanel: "+dir+" fill complete; hasMoreResults:"+hasMoreResults);
if (hasMoreResults) { if (hasMoreResults) {
// further pagination requests have been disabled until now, so // further pagination requests have been disabled until now, so
@ -456,7 +570,7 @@ module.exports = React.createClass({
var boundingRect = node.getBoundingClientRect(); var boundingRect = node.getBoundingClientRect();
var scrollDelta = boundingRect.bottom + pixelOffset - wrapperRect.bottom; 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+")"); pixelOffset + " (delta: "+scrollDelta+")");
if(scrollDelta != 0) { if(scrollDelta != 0) {
@ -468,7 +582,7 @@ module.exports = React.createClass({
_saveScrollState: function() { _saveScrollState: function() {
if (this.props.stickyBottom && this.isAtBottom()) { if (this.props.stickyBottom && this.isAtBottom()) {
this.scrollState = { stuckAtBottom: true }; this.scrollState = { stuckAtBottom: true };
debuglog("Saved scroll state", this.scrollState); debuglog("ScrollPanel: Saved scroll state", this.scrollState);
return; return;
} }
@ -486,13 +600,13 @@ module.exports = React.createClass({
stuckAtBottom: false, stuckAtBottom: false,
trackedScrollToken: node.dataset.scrollToken, trackedScrollToken: node.dataset.scrollToken,
pixelOffset: wrapperRect.bottom - boundingRect.bottom, pixelOffset: wrapperRect.bottom - boundingRect.bottom,
} };
debuglog("Saved scroll state", this.scrollState); debuglog("ScrollPanel: saved scroll state", this.scrollState);
return; 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() { _restoreSavedScrollState: function() {
@ -526,7 +640,7 @@ module.exports = React.createClass({
this._lastSetScroll = scrollNode.scrollTop; this._lastSetScroll = scrollNode.scrollTop;
} }
debuglog("Set scrollTop:", scrollNode.scrollTop, debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop,
"requested:", scrollTop, "requested:", scrollTop,
"_lastSetScroll:", this._lastSetScroll); "_lastSetScroll:", this._lastSetScroll);
}, },

View file

@ -38,7 +38,7 @@ if (DEBUG) {
// using bind means that we get to keep useful line numbers in the console // using bind means that we get to keep useful line numbers in the console
var debuglog = console.log.bind(console); var debuglog = console.log.bind(console);
} else { } else {
var debuglog = function () {}; var debuglog = function() {};
} }
/* /*
@ -108,7 +108,9 @@ var TimelinePanel = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { 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', 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. // set off a pagination request.
onMessageListFillRequest: function(backwards) { onMessageListFillRequest: function(backwards) {
var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; var dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
@ -292,7 +322,7 @@ var TimelinePanel = React.createClass({
}); });
}, },
onMessageListScroll: function () { onMessageListScroll: function() {
if (this.props.onScroll) { if (this.props.onScroll) {
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 we're at the end of the live timeline, append the pending events
if (this.props.timelineSet.room && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { 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}; var updatedState = {events: events};
@ -534,8 +564,9 @@ var TimelinePanel = React.createClass({
// first find where the current RM is // first find where the current RM is
for (var i = 0; i < events.length; i++) { for (var i = 0; i < events.length; i++) {
if (events[i].getId() == this.state.readMarkerEventId) if (events[i].getId() == this.state.readMarkerEventId) {
break; break;
}
} }
if (i >= events.length) { if (i >= events.length) {
return; return;
@ -614,7 +645,7 @@ var TimelinePanel = React.createClass({
var tl = this.props.timelineSet.getTimelineForEvent(rmId); var tl = this.props.timelineSet.getTimelineForEvent(rmId);
var rmTs; var rmTs;
if (tl) { if (tl) {
var event = tl.getEvents().find((e) => { return e.getId() == rmId }); var event = tl.getEvents().find((e) => { return e.getId() == rmId; });
if (event) { if (event) {
rmTs = event.getTs(); 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') { if (error.errcode == 'M_FORBIDDEN') {
message += "you do not have permission to view the message in question."; message += "you do not have permission to view the message in question.";
} else { } else {
@ -791,7 +822,7 @@ var TimelinePanel = React.createClass({
description: message, description: message,
onFinished: onFinished, onFinished: onFinished,
}); });
} };
var prom = this._timelineWindow.load(eventId, INITIAL_SIZE); var prom = this._timelineWindow.load(eventId, INITIAL_SIZE);
@ -813,7 +844,7 @@ var TimelinePanel = React.createClass({
timelineLoading: true, timelineLoading: true,
}); });
prom = prom.then(onLoaded, onError) prom = prom.then(onLoaded, onError);
} }
prom.done(); 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 we're at the end of the live timeline, append the pending events
if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { if (!this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) {
events.push(... this.props.timelineSet.getPendingEvents()); events.push(...this.props.timelineSet.getPendingEvents());
} }
return events; return events;
@ -900,8 +931,9 @@ var TimelinePanel = React.createClass({
_getCurrentReadReceipt: function(ignoreSynthesized) { _getCurrentReadReceipt: function(ignoreSynthesized) {
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
// the client can be null on logout // the client can be null on logout
if (client == null) if (client == null) {
return null; return null;
}
var myUserId = client.credentials.userId; var myUserId = client.credentials.userId;
return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized);
@ -984,6 +1016,7 @@ var TimelinePanel = React.createClass({
stickyBottom={ stickyBottom } stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll } onScroll={ this.onMessageListScroll }
onFillRequest={ this.onMessageListFillRequest } onFillRequest={ this.onMessageListFillRequest }
onUnfillRequest={ this.onMessageListUnfillRequest }
opacity={ this.props.opacity } opacity={ this.props.opacity }
className={ this.props.className } className={ this.props.className }
tileShape={ this.props.tileShape } tileShape={ this.props.tileShape }

View file

@ -57,7 +57,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
// }]; // }];
if (uploads.length == 0) { if (uploads.length == 0) {
return <div /> return <div />;
} }
var upload; var upload;
@ -68,7 +68,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
} }
} }
if (!upload) { if (!upload) {
return <div /> return <div />;
} }
var innerProgressStyle = { var innerProgressStyle = {
@ -76,7 +76,7 @@ module.exports = React.createClass({displayName: 'UploadBar',
}; };
var uploadedSize = filesize(upload.loaded); var uploadedSize = filesize(upload.loaded);
var totalSize = filesize(upload.total); var totalSize = filesize(upload.total);
if (uploadedSize.replace(/^.* /,'') === totalSize.replace(/^.* /,'')) { if (uploadedSize.replace(/^.* /, '') === totalSize.replace(/^.* /, '')) {
uploadedSize = uploadedSize.replace(/ .*/, ''); uploadedSize = uploadedSize.replace(/ .*/, '');
} }

View file

@ -26,12 +26,61 @@ var UserSettingsStore = require('../../UserSettingsStore');
var GeminiScrollbar = require('react-gemini-scrollbar'); var GeminiScrollbar = require('react-gemini-scrollbar');
var Email = require('../../email'); var Email = require('../../email');
var AddThreepid = require('../../AddThreepid'); 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 // if this looks like a release, use the 'version' from package.json; else use
// the git sha. // the git sha.
const REACT_SDK_VERSION = const REACT_SDK_VERSION =
'dist' in package_json ? package_json.version : package_json.gitHead || "<local>"; 'dist' in package_json ? package_json.version : package_json.gitHead || "<local>";
// 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({ module.exports = React.createClass({
displayName: 'UserSettings', displayName: 'UserSettings',
@ -43,6 +92,9 @@ module.exports = React.createClass({
// True to show the 'labs' section of experimental features // True to show the 'labs' section of experimental features
enableLabs: React.PropTypes.bool, 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 // true if RightPanel is collapsed
collapsedRhs: React.PropTypes.bool, collapsedRhs: React.PropTypes.bool,
}, },
@ -61,6 +113,7 @@ module.exports = React.createClass({
phase: "UserSettings.LOADING", // LOADING, DISPLAY phase: "UserSettings.LOADING", // LOADING, DISPLAY
email_add_pending: false, email_add_pending: false,
vectorVersion: null, 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({ dis.dispatch({
action: 'ui_opacity', action: 'ui_opacity',
sideOpacity: 0.3, sideOpacity: 0.3,
middleOpacity: 0.3, middleOpacity: 0.3,
}); });
this._refreshFromServer(); this._refreshFromServer();
var syncedSettings = UserSettingsStore.getSyncedSettings();
if (!syncedSettings.theme) {
syncedSettings.theme = 'light';
}
this._syncedSettings = syncedSettings;
}, },
componentDidMount: function() { componentDidMount: function() {
@ -101,6 +166,10 @@ module.exports = React.createClass({
middleOpacity: 1.0, middleOpacity: 1.0,
}); });
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
let cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomMember.membership", this._onInviteStateChange);
}
}, },
_refreshFromServer: function() { _refreshFromServer: function() {
@ -164,8 +233,26 @@ module.exports = React.createClass({
}, },
onLogoutClicked: function(ev) { onLogoutClicked: function(ev) {
var LogoutPrompt = sdk.getComponent('dialogs.LogoutPrompt'); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
this.logoutModal = Modal.createDialog(LogoutPrompt); Modal.createDialog(QuestionDialog, {
title: "Sign out?",
description:
<div>
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 <a href="https://github.com/vector-im/riot-web/issues/2108">will be improved</a>,
but for now be warned.
</div>,
button: "Sign out",
onFinished: (confirmed) => {
if (confirmed) {
dis.dispatch({action: 'logout'});
if (this.props.onFinished) {
this.props.onFinished();
}
}
},
});
}, },
onPasswordChangeError: function(err) { onPasswordChangeError: function(err) {
@ -237,6 +324,31 @@ module.exports = React.createClass({
this.setState({email_add_pending: true}); 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) { onEmailDialogFinished: function(ok) {
if (ok) { if (ok) {
this.verifyEmailAddress(); this.verifyEmailAddress();
@ -257,8 +369,8 @@ module.exports = React.createClass({
this.setState({email_add_pending: false}); this.setState({email_add_pending: false});
if (err.errcode == 'M_THREEPID_AUTH_FAILED') { if (err.errcode == 'M_THREEPID_AUTH_FAILED') {
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var message = "Unable to verify email address. " 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." message += "Please check your email and click on the link it contains. Once this is done, click continue.";
Modal.createDialog(QuestionDialog, { Modal.createDialog(QuestionDialog, {
title: "Verification Pending", title: "Verification Pending",
description: message, description: message,
@ -280,91 +392,185 @@ module.exports = React.createClass({
Modal.createDialog(DeactivateAccountDialog, {}); 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 (
<div>
<h3>Referral</h3>
<div className="mx_UserSettings_section">
Refer a friend to Riot: <a href={href}>{href}</a>
</div>
</div>
);
},
_renderUserInterfaceSettings: function() { _renderUserInterfaceSettings: function() {
var client = MatrixClientPeg.get(); 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 ( return (
<div> <div>
<h3>User Interface</h3> <h3>User Interface</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<div className="mx_UserSettings_toggle"> { this._renderUrlPreviewSelector() }
<input id="urlPreviewsDisabled" { SETTINGS_LABELS.map( this._renderSyncedSetting ) }
type="checkbox" { THEMES.map( this._renderThemeSelector ) }
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
onChange={ e => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
/>
<label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default
</label>
</div>
</div> </div>
{ settingsLabels.forEach( setting => {
<div className="mx_UserSettings_toggle">
<input id={ setting.id }
type="checkbox"
defaultChecked={ syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/>
<label htmlFor={ setting.id }>
{ settings.label }
</label>
</div>
})}
</div> </div>
); );
}, },
_renderUrlPreviewSelector: function() {
return <div className="mx_UserSettings_toggle">
<input id="urlPreviewsDisabled"
type="checkbox"
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
onChange={ e => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
/>
<label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default
</label>
</div>;
},
_renderSyncedSetting: function(setting) {
return <div className="mx_UserSettings_toggle" key={ setting.id }>
<input id={ setting.id }
type="checkbox"
defaultChecked={ this._syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/>
<label htmlFor={ setting.id }>
{ setting.label }
</label>
</div>;
},
_renderThemeSelector: function(setting) {
return <div className="mx_UserSettings_toggle" key={ setting.id + "_" + setting.value }>
<input id={ setting.id + "_" + setting.value }
type="radio"
name={ setting.id }
value={ setting.value }
defaultChecked={ this._syncedSettings[setting.id] === setting.value }
onChange={ e => {
if (e.target.checked) {
UserSettingsStore.setSyncedSetting(setting.id, setting.value);
}
dis.dispatch({
action: 'set_theme',
value: setting.value,
});
}
}
/>
<label htmlFor={ setting.id + "_" + setting.value }>
{ setting.label }
</label>
</div>;
},
_renderCryptoInfo: function() { _renderCryptoInfo: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { const client = MatrixClientPeg.get();
return null; const deviceId = client.deviceId;
const identityKey = client.getDeviceEd25519Key() || "<not supported>";
let exportButton = null,
importButton = null;
if (client.isCryptoEnabled) {
exportButton = (
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onExportE2eKeysClicked}>
Export E2E room keys
</AccessibleButton>
);
importButton = (
<AccessibleButton className="mx_UserSettings_button"
onClick={this._onImportE2eKeysClicked}>
Import E2E room keys
</AccessibleButton>
);
} }
var client = MatrixClientPeg.get();
var deviceId = client.deviceId;
var identityKey = client.getDeviceEd25519Key() || "<not supported>";
var myDevice = client.getStoredDevicesForUser(MatrixClientPeg.get().credentials.userId)[0];
return ( return (
<div> <div>
<h3>Cryptography</h3> <h3>Cryptography</h3>
<div className="mx_UserSettings_section mx_UserSettings_cryptoSection"> <div className="mx_UserSettings_section mx_UserSettings_cryptoSection">
<ul> <ul>
<li><label>Device name:</label> <span>{ myDevice.getDisplayName() }</span></li> <li><label>Device ID:</label> <span><code>{deviceId}</code></span></li>
<li><label>Device ID:</label> <span><code>{deviceId}</code></span></li> <li><label>Device key:</label> <span><code><b>{identityKey}</b></code></span></li>
<li><label>Device key:</label> <span><code><b>{identityKey}</b></code></span></li>
</ul> </ul>
{exportButton}
{importButton}
</div> </div>
</div> </div>
); );
}, },
_renderDevicesPanel: function() { _renderDevicesPanel: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var DevicesPanel = sdk.getComponent('settings.DevicesPanel'); var DevicesPanel = sdk.getComponent('settings.DevicesPanel');
return ( return (
<div> <div>
@ -374,7 +580,24 @@ module.exports = React.createClass({
); );
}, },
_renderLabs: function () { _renderBugReport: function() {
if (!SdkConfig.get().bug_report_endpoint_url) {
return <div />
}
return (
<div>
<h3>Bug Report</h3>
<div className="mx_UserSettings_section">
<p>Found a bug?</p>
<button className="mx_UserSettings_button danger"
onClick={this._onBugReportClicked}>Report it
</button>
</div>
</div>
);
},
_renderLabs: function() {
// default to enabled if undefined // default to enabled if undefined
if (this.props.enableLabs === false) return null; if (this.props.enableLabs === false) return null;
@ -410,7 +633,7 @@ module.exports = React.createClass({
{features} {features}
</div> </div>
</div> </div>
) );
}, },
_renderDeactivateAccount: function() { _renderDeactivateAccount: function() {
@ -420,15 +643,49 @@ module.exports = React.createClass({
return <div> return <div>
<h3>Deactivate Account</h3> <h3>Deactivate Account</h3>
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<button className="mx_UserSettings_button danger" <AccessibleButton className="mx_UserSettings_button danger"
onClick={this._onDeactivateAccountClicked}>Deactivate my account onClick={this._onDeactivateAccountClicked}>Deactivate my account
</button> </AccessibleButton>
</div> </div>
</div>; </div>;
}, },
_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 = <Spinner />;
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 = (
<AccessibleButton className="mx_UserSettings_button danger"
onClick={this._onRejectAllInvitesClicked.bind(this, invitedRooms)}>
Reject all {invitedRooms.length} invites
</AccessibleButton>
);
}
return <div>
<h3>Bulk Options</h3>
<div className="mx_UserSettings_section">
{reject}
</div>
</div>;
},
nameForMedium: function(medium) {
if (medium == 'msisdn') return 'Phone';
return medium[0].toUpperCase() + medium.slice(1);
},
render: function() { render: function() {
var self = this;
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
switch (this.state.phase) { switch (this.state.phase) {
case "UserSettings.LOADING": case "UserSettings.LOADING":
@ -452,15 +709,18 @@ module.exports = React.createClass({
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
); );
var threepidsSection = this.state.threepids.map(function(val, pidIndex) { var threepidsSection = this.state.threepids.map((val, pidIndex) => {
var id = "email-" + val.address; const id = "3pid-" + val.address;
return ( return (
<div className="mx_UserSettings_profileTableRow" key={pidIndex}> <div className="mx_UserSettings_profileTableRow" key={pidIndex}>
<div className="mx_UserSettings_profileLabelCell"> <div className="mx_UserSettings_profileLabelCell">
<label htmlFor={id}>Email</label> <label htmlFor={id}>{this.nameForMedium(val.medium)}</label>
</div> </div>
<div className="mx_UserSettings_profileInputCell"> <div className="mx_UserSettings_profileInputCell">
<input key={val.address} id={id} value={val.address} disabled /> <input type="text" key={val.address} id={id} value={val.address} disabled />
</div>
<div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/cancel-small.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} />
</div> </div>
</div> </div>
); );
@ -482,7 +742,7 @@ module.exports = React.createClass({
blurToCancel={ false } blurToCancel={ false }
onValueChanged={ this.onAddThreepidClicked } /> onValueChanged={ this.onAddThreepidClicked } />
</div> </div>
<div className="mx_UserSettings_addThreepid"> <div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/plus.svg" width="14" height="14" alt="Add" onClick={ this.onAddThreepidClicked.bind(this, undefined, true) }/> <img src="img/plus.svg" width="14" height="14" alt="Add" onClick={ this.onAddThreepidClicked.bind(this, undefined, true) }/>
</div> </div>
</div> </div>
@ -563,7 +823,7 @@ module.exports = React.createClass({
</div> </div>
<div className="mx_UserSettings_avatarPicker_edit"> <div className="mx_UserSettings_avatarPicker_edit">
<label htmlFor="avatarInput" ref="file_label"> <label htmlFor="avatarInput" ref="file_label">
<img src="img/camera.svg" <img src="img/camera.svg" className="mx_filterFlipColor"
alt="Upload avatar" title="Upload avatar" alt="Upload avatar" title="Upload avatar"
width="17" height="15" /> width="17" height="15" />
</label> </label>
@ -576,19 +836,23 @@ module.exports = React.createClass({
<div className="mx_UserSettings_section"> <div className="mx_UserSettings_section">
<div className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}> <AccessibleButton className="mx_UserSettings_logout mx_UserSettings_button" onClick={this.onLogoutClicked}>
Sign out Sign out
</div> </AccessibleButton>
{accountJsx} {accountJsx}
</div> </div>
{this._renderReferral()}
{notification_area} {notification_area}
{this._renderUserInterfaceSettings()} {this._renderUserInterfaceSettings()}
{this._renderLabs()} {this._renderLabs()}
{this._renderDevicesPanel()} {this._renderDevicesPanel()}
{this._renderCryptoInfo()} {this._renderCryptoInfo()}
{this._renderBulkOptions()}
{this._renderBugReport()}
<h3>Advanced</h3> <h3>Advanced</h3>

View file

@ -58,7 +58,7 @@ module.exports = React.createClass({
this.setState({ this.setState({
progress: null progress: null
}); });
}) });
}, },
onVerify: function(ev) { onVerify: function(ev) {
@ -71,7 +71,7 @@ module.exports = React.createClass({
this.setState({ progress: "complete" }); this.setState({ progress: "complete" });
}, (err) => { }, (err) => {
this.showErrorDialog(err.message); this.showErrorDialog(err.message);
}) });
}, },
onSubmitForm: function(ev) { onSubmitForm: function(ev) {
@ -87,10 +87,26 @@ module.exports = React.createClass({
this.showErrorDialog("New passwords must match each other."); this.showErrorDialog("New passwords must match each other.");
} }
else { else {
this.submitPasswordReset( var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
this.state.enteredHomeserverUrl, this.state.enteredIdentityServerUrl, Modal.createDialog(QuestionDialog, {
this.state.email, this.state.password title: "Warning",
); description:
<div>
Resetting password will currently reset any end-to-end encryption keys on all devices,
making encrypted chat history unreadable.
In future this <a href="https://github.com/vector-im/riot-web/issues/2671">may be improved</a>,
but for now be warned.
</div>,
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; var resetPasswordJsx;
if (this.state.progress === "sending_email") { if (this.state.progress === "sending_email") {
resetPasswordJsx = <Spinner /> resetPasswordJsx = <Spinner />;
} }
else if (this.state.progress === "sent_email") { else if (this.state.progress === "sent_email") {
resetPasswordJsx = ( resetPasswordJsx = (

View file

@ -173,7 +173,7 @@ module.exports = React.createClass({
}, },
_getCurrentFlowStep: function() { _getCurrentFlowStep: function() {
return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null;
}, },
_setStateFromError: function(err, isLoginAttempt) { _setStateFromError: function(err, isLoginAttempt) {
@ -195,7 +195,7 @@ module.exports = React.createClass({
} }
let errorText = "Error: Problem communicating with the given homeserver " + let errorText = "Error: Problem communicating with the given homeserver " +
(errCode ? "(" + errCode + ")" : "") (errCode ? "(" + errCode + ")" : "");
if (err.cors === 'rejected') { if (err.cors === 'rejected') {
if (window.location.protocol === 'https:' && if (window.location.protocol === 'https:' &&
@ -203,7 +203,7 @@ module.exports = React.createClass({
!this.state.enteredHomeserverUrl.startsWith("http"))) !this.state.enteredHomeserverUrl.startsWith("http")))
{ {
errorText = <span> errorText = <span>
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 <a href='https://www.google.com/search?&q=enable%20unsafe%20scripts'>enable unsafe scripts</a> Either use HTTPS or <a href='https://www.google.com/search?&q=enable%20unsafe%20scripts'>enable unsafe scripts</a>
</span>; </span>;
} }
@ -258,7 +258,7 @@ module.exports = React.createClass({
loginAsGuestJsx = loginAsGuestJsx =
<a className="mx_Login_create" onClick={this._onLoginAsGuestClick} href="#"> <a className="mx_Login_create" onClick={this._onLoginAsGuestClick} href="#">
Login as guest Login as guest
</a> </a>;
} }
var returnToAppJsx; var returnToAppJsx;
@ -266,7 +266,7 @@ module.exports = React.createClass({
returnToAppJsx = returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#"> <a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app Return to app
</a> </a>;
} }
return ( return (

View file

@ -25,6 +25,7 @@ var ServerConfig = require("../../views/login/ServerConfig");
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var RegistrationForm = require("../../views/login/RegistrationForm"); var RegistrationForm = require("../../views/login/RegistrationForm");
var CaptchaForm = require("../../views/login/CaptchaForm"); var CaptchaForm = require("../../views/login/CaptchaForm");
var RtsClient = require("../../../RtsClient");
var MIN_PASSWORD_LENGTH = 6; var MIN_PASSWORD_LENGTH = 6;
@ -47,8 +48,16 @@ module.exports = React.createClass({
defaultIsUrl: React.PropTypes.string, defaultIsUrl: React.PropTypes.string,
brand: React.PropTypes.string, brand: React.PropTypes.string,
email: React.PropTypes.string, email: React.PropTypes.string,
referrer: React.PropTypes.string,
username: React.PropTypes.string, username: React.PropTypes.string,
guestAccessToken: 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, defaultDeviceDisplayName: React.PropTypes.string,
@ -60,6 +69,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
busy: false, busy: false,
teamServerBusy: false,
errorText: null, errorText: null,
// We remember the values entered by the user because // We remember the values entered by the user because
// the registration form will be unmounted during the // the registration form will be unmounted during the
@ -75,6 +85,7 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this._unmounted = false;
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
// attach this to the instance rather than this.state since it isn't UI // attach this to the instance rather than this.state since it isn't UI
this.registerLogic = new Signup.Register( this.registerLogic = new Signup.Register(
@ -88,10 +99,40 @@ module.exports = React.createClass({
this.registerLogic.setIdSid(this.props.idSid); this.registerLogic.setIdSid(this.props.idSid);
this.registerLogic.setGuestAccessToken(this.props.guestAccessToken); this.registerLogic.setGuestAccessToken(this.props.guestAccessToken);
this.registerLogic.recheckState(); 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() { componentWillUnmount: function() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this._unmounted = true;
}, },
componentDidMount: function() { componentDidMount: function() {
@ -169,6 +210,43 @@ module.exports = React.createClass({
accessToken: response.access_token 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) { if (self.props.brand) {
MatrixClientPeg.get().getPushers().done((resp)=>{ MatrixClientPeg.get().getPushers().done((resp)=>{
var pushers = resp.pushers; var pushers = resp.pushers;
@ -238,7 +316,15 @@ module.exports = React.createClass({
}); });
}, },
onTeamSelected: function(teamSelected) {
if (!this._unmounted) {
this.setState({ teamSelected });
}
},
_getRegisterContentJsx: function() { _getRegisterContentJsx: function() {
const Spinner = sdk.getComponent("elements.Spinner");
var currStep = this.registerLogic.getStep(); var currStep = this.registerLogic.getStep();
var registerStep; var registerStep;
switch (currStep) { switch (currStep) {
@ -248,16 +334,23 @@ module.exports = React.createClass({
case "Register.STEP_m.login.dummy": case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading // NB. Our 'username' prop is specifically for upgrading
// a guest account // a guest account
if (this.state.teamServerBusy) {
registerStep = <Spinner />;
break;
}
registerStep = ( registerStep = (
<RegistrationForm <RegistrationForm
showEmail={true} showEmail={true}
defaultUsername={this.state.formVals.username} defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email} defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password} defaultPassword={this.state.formVals.password}
teamsConfig={this.state.teamsConfig}
guestUsername={this.props.username} guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} /> onRegisterClick={this.onFormSubmit}
onTeamSelected={this.onTeamSelected}
/>
); );
break; break;
case "Register.STEP_m.login.email.identity": case "Register.STEP_m.login.email.identity":
@ -273,6 +366,7 @@ module.exports = React.createClass({
if (serverParams && serverParams["m.login.recaptcha"]) { if (serverParams && serverParams["m.login.recaptcha"]) {
publicKey = serverParams["m.login.recaptcha"].public_key; publicKey = serverParams["m.login.recaptcha"].public_key;
} }
registerStep = ( registerStep = (
<CaptchaForm sitePublicKey={publicKey} <CaptchaForm sitePublicKey={publicKey}
onCaptchaResponse={this.onCaptchaResponse} onCaptchaResponse={this.onCaptchaResponse}
@ -285,7 +379,6 @@ module.exports = React.createClass({
} }
var busySpinner; var busySpinner;
if (this.state.busy) { if (this.state.busy) {
var Spinner = sdk.getComponent("elements.Spinner");
busySpinner = ( busySpinner = (
<Spinner /> <Spinner />
); );
@ -296,7 +389,7 @@ module.exports = React.createClass({
returnToAppJsx = returnToAppJsx =
<a className="mx_Login_create" onClick={this.props.onCancelClick} href="#"> <a className="mx_Login_create" onClick={this.props.onCancelClick} href="#">
Return to app Return to app
</a> </a>;
} }
return ( return (
@ -330,7 +423,7 @@ module.exports = React.createClass({
return ( return (
<div className="mx_Login"> <div className="mx_Login">
<div className="mx_Login_box"> <div className="mx_Login_box">
<LoginHeader /> <LoginHeader icon={this.state.teamSelected ? this.state.teamSelected.icon : null}/>
{this._getRegisterContentJsx()} {this._getRegisterContentJsx()}
<LoginFooter /> <LoginFooter />
</div> </div>

View file

@ -19,6 +19,7 @@ limitations under the License.
var React = require('react'); var React = require('react');
var AvatarLogic = require("../../../Avatar"); var AvatarLogic = require("../../../Avatar");
import sdk from '../../../index'; import sdk from '../../../index';
import AccessibleButton from '../elements/AccessibleButton';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'BaseAvatar', displayName: 'BaseAvatar',
@ -41,7 +42,7 @@ module.exports = React.createClass({
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
defaultToInitialLetter: true defaultToInitialLetter: true
} };
}, },
getInitialState: function() { getInitialState: function() {
@ -138,7 +139,7 @@ module.exports = React.createClass({
const { const {
name, idName, title, url, urls, width, height, resizeMethod, name, idName, title, url, urls, width, height, resizeMethod,
defaultToInitialLetter, viewUserOnClick, defaultToInitialLetter, onClick,
...otherProps ...otherProps
} = this.props; } = this.props;
@ -156,12 +157,24 @@ module.exports = React.createClass({
</span> </span>
); );
} }
return ( if (onClick != null) {
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl} return (
onError={this.onError} <AccessibleButton className="mx_BaseAvatar" onClick={onClick}>
width={width} height={height} <img className="mx_BaseAvatar_image" src={imageUrl}
title={title} alt="" onError={this.onError}
{...otherProps} /> width={width} height={height}
); title={title} alt=""
{...otherProps} />
</AccessibleButton>
);
} else {
return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError}
width={width} height={height}
title={title} alt=""
{...otherProps} />
);
}
} }
}); });

View file

@ -33,6 +33,7 @@ module.exports = React.createClass({
onClick: React.PropTypes.func, onClick: React.PropTypes.func,
// Whether the onClick of the avatar should be overriden to dispatch 'view_user' // Whether the onClick of the avatar should be overriden to dispatch 'view_user'
viewUserOnClick: React.PropTypes.bool, viewUserOnClick: React.PropTypes.bool,
title: React.PropTypes.string,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -41,7 +42,7 @@ module.exports = React.createClass({
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
viewUserOnClick: false, viewUserOnClick: false,
} };
}, },
getInitialState: function() { getInitialState: function() {
@ -58,26 +59,26 @@ module.exports = React.createClass({
} }
return { return {
name: props.member.name, name: props.member.name,
title: props.member.userId, title: props.title || props.member.userId,
imageUrl: Avatar.avatarUrlForMember(props.member, imageUrl: Avatar.avatarUrlForMember(props.member,
props.width, props.width,
props.height, props.height,
props.resizeMethod) props.resizeMethod)
} };
}, },
render: function() { render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); 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 = () => { onClick = () => {
dispatcher.dispatch({ dispatcher.dispatch({
action: 'view_user', action: 'view_user',
member: this.props.member, member: this.props.member,
}); });
} };
} }
return ( return (

View file

@ -39,7 +39,7 @@ module.exports = React.createClass({
height: 36, height: 36,
resizeMethod: 'crop', resizeMethod: 'crop',
oobData: {}, oobData: {},
} };
}, },
getInitialState: function() { getInitialState: function() {
@ -51,7 +51,7 @@ module.exports = React.createClass({
componentWillReceiveProps: function(newProps) { componentWillReceiveProps: function(newProps) {
this.setState({ this.setState({
urls: this.getImageUrls(newProps) urls: this.getImageUrls(newProps)
}) });
}, },
getImageUrls: function(props) { getImageUrls: function(props) {

View file

@ -40,7 +40,7 @@ module.exports = React.createClass({
}, },
onValueChanged: function(ev) { onValueChanged: function(ev) {
this.props.onChange(ev.target.value) this.props.onChange(ev.target.value);
}, },
render: function() { render: function() {

View file

@ -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 (
<div onKeyDown={this._onKeyDown} className={this.props.className}>
<div className='mx_Dialog_title'>
{ this.props.title }
</div>
{ this.props.children }
</div>
);
},
});

View file

@ -14,19 +14,30 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require("react"); import React from 'react';
var classNames = require('classnames'); import classNames from 'classnames';
var sdk = require("../../../index"); import sdk from '../../../index';
var Invite = require("../../../Invite"); import { getAddressType, inviteMultipleToRoom } from '../../../Invite';
var createRoom = require("../../../createRoom"); import createRoom from '../../../createRoom';
var MatrixClientPeg = require("../../../MatrixClientPeg"); import MatrixClientPeg from '../../../MatrixClientPeg';
var DMRoomMap = require('../../../utils/DMRoomMap'); import DMRoomMap from '../../../utils/DMRoomMap';
var rate_limited_func = require("../../../ratelimitedfunc"); import rate_limited_func from '../../../ratelimitedfunc';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
var Modal = require('../../../Modal'); import Modal from '../../../Modal';
import AccessibleButton from '../elements/AccessibleButton';
import q from 'q';
const TRUNCATE_QUERY_LIST = 40; 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({ module.exports = React.createClass({
displayName: "ChatInviteDialog", displayName: "ChatInviteDialog",
propTypes: { propTypes: {
@ -48,7 +59,7 @@ module.exports = React.createClass({
title: "Start a chat", title: "Start a chat",
description: "Who would you like to communicate with?", description: "Who would you like to communicate with?",
value: "", value: "",
placeholder: "User ID, Name or email", placeholder: "Email, name or matrix ID",
button: "Start Chat", button: "Start Chat",
focus: true focus: true
}; };
@ -57,7 +68,14 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
error: false, error: false,
// List of AddressTile.InviteAddressType objects represeting
// the list of addresses we're going to invite
inviteList: [], inviteList: [],
// List of AddressTile.InviteAddressType objects represeting
// the set of autocompletion results for the current search
// query.
queryList: [], queryList: [],
}; };
}, },
@ -71,15 +89,12 @@ module.exports = React.createClass({
}, },
onButtonClick: function() { 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 // 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 // If there is and it's valid add it to the local inviteList
var check = Invite.isValidAddress(this.refs.textinput.value); if (this.refs.textinput.value !== '') {
if (check === true || check === null) { inviteList = this._addInputToList();
inviteList.push(this.refs.textinput.value); if (inviteList === null) return;
} else if (this.refs.textinput.value.length > 0) {
this.setState({ error: true });
return;
} }
if (inviteList.length > 0) { if (inviteList.length > 0) {
@ -119,15 +134,15 @@ module.exports = React.createClass({
} else if (e.keyCode === 38) { // up arrow } else if (e.keyCode === 38) { // up arrow
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.addressSelector.onKeyUp(); this.addressSelector.moveSelectionUp();
} else if (e.keyCode === 40) { // down arrow } else if (e.keyCode === 40) { // down arrow
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.addressSelector.onKeyDown(); this.addressSelector.moveSelectionDown();
} else if (this.state.queryList.length > 0 && (e.keyCode === 188, e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab } else if (this.state.queryList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
this.addressSelector.onKeySelect(); this.addressSelector.chooseSelection();
} else if (this.refs.textinput.value.length === 0 && this.state.inviteList.length && e.keyCode === 8) { // backspace } else if (this.refs.textinput.value.length === 0 && this.state.inviteList.length && e.keyCode === 8) { // backspace
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
@ -135,33 +150,56 @@ module.exports = React.createClass({
} else if (e.keyCode === 13) { // enter } else if (e.keyCode === 13) { // enter
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); 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 } else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
var check = Invite.isValidAddress(this.refs.textinput.value); this._addInputToList();
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 });
}
} }
}, },
onQueryChanged: function(ev) { onQueryChanged: function(ev) {
var query = ev.target.value; const query = ev.target.value;
var queryList = []; let queryList = [];
// Only do search if there is something to search // 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) => { queryList = this._userList.filter((user) => {
return this._matches(query, 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({ this.setState({
@ -179,7 +217,8 @@ module.exports = React.createClass({
inviteList: inviteList, inviteList: inviteList,
queryList: [], queryList: [],
}); });
} if (this._cancelThreepidLookup) this._cancelThreepidLookup();
};
}, },
onClick: function(index) { onClick: function(index) {
@ -191,11 +230,12 @@ module.exports = React.createClass({
onSelected: function(index) { onSelected: function(index) {
var inviteList = this.state.inviteList.slice(); var inviteList = this.state.inviteList.slice();
inviteList.push(this.state.queryList[index].userId); inviteList.push(this.state.queryList[index]);
this.setState({ this.setState({
inviteList: inviteList, inviteList: inviteList,
queryList: [], queryList: [],
}); });
if (this._cancelThreepidLookup) this._cancelThreepidLookup();
}, },
_getDirectMessageRoom: function(addr) { _getDirectMessageRoom: function(addr) {
@ -226,10 +266,14 @@ module.exports = React.createClass({
return; return;
} }
const addrTexts = addrs.map((addr) => {
return addr.address;
});
if (this.props.roomId) { if (this.props.roomId) {
// Invite new user to a room // Invite new user to a room
var self = this; var self = this;
Invite.inviteMultipleToRoom(this.props.roomId, addrs) inviteMultipleToRoom(this.props.roomId, addrTexts)
.then(function(addrs) { .then(function(addrs) {
var room = MatrixClientPeg.get().getRoom(self.props.roomId); var room = MatrixClientPeg.get().getRoom(self.props.roomId);
return self._showAnyInviteErrors(addrs, room); return self._showAnyInviteErrors(addrs, room);
@ -244,9 +288,9 @@ module.exports = React.createClass({
return null; return null;
}) })
.done(); .done();
} else if (this._isDmChat(addrs)) { } else if (this._isDmChat(addrTexts)) {
// Start the DM chat // Start the DM chat
createRoom({dmUserId: addrs[0]}) createRoom({dmUserId: addrTexts[0]})
.catch(function(err) { .catch(function(err) {
console.error(err.stack); console.error(err.stack);
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -263,7 +307,7 @@ module.exports = React.createClass({
var room; var room;
createRoom().then(function(roomId) { createRoom().then(function(roomId) {
room = MatrixClientPeg.get().getRoom(roomId); room = MatrixClientPeg.get().getRoom(roomId);
return Invite.inviteMultipleToRoom(roomId, addrs); return inviteMultipleToRoom(roomId, addrTexts);
}) })
.then(function(addrs) { .then(function(addrs) {
return self._showAnyInviteErrors(addrs, room); return self._showAnyInviteErrors(addrs, room);
@ -281,7 +325,7 @@ module.exports = React.createClass({
} }
// Close - this will happen before the above, as that is async // 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() { _updateUserList: new rate_limited_func(function() {
@ -315,19 +359,27 @@ module.exports = React.createClass({
return true; return true;
} }
// split spaces in name and try matching constituent parts // Try to find the query following a "word boundary", except that
var parts = name.split(" "); // this does avoids using \b because it only considers letters from
for (var i = 0; i < parts.length; i++) { // the roman alphabet to be word characters.
if (parts[i].indexOf(query) === 0) { // Instead, we look for the query following either:
return true; // * 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; return false;
}, },
_isOnInviteList: function(uid) { _isOnInviteList: function(uid) {
for (let i = 0; i < this.state.inviteList.length; i++) { 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; return true;
} }
} }
@ -335,7 +387,7 @@ module.exports = React.createClass({
}, },
_isDmChat: function(addrs) { _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; return true;
} else { } else {
return false; return false;
@ -361,9 +413,74 @@ module.exports = React.createClass({
return addrs; 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() { render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
var AddressSelector = sdk.getComponent("elements.AddressSelector"); const AddressSelector = sdk.getComponent("elements.AddressSelector");
this.scrollElement = null; this.scrollElement = null;
var query = []; var query = [];
@ -394,13 +511,18 @@ module.exports = React.createClass({
var error; var error;
var addressSelector; var addressSelector;
if (this.state.error) { if (this.state.error) {
error = <div className="mx_ChatInviteDialog_error">You have entered an invalid contact. Try using their Matrix ID or email address.</div> error = <div className="mx_ChatInviteDialog_error">You have entered an invalid contact. Try using their Matrix ID or email address.</div>;
} else { } else {
const addressSelectorHeader = <div className="mx_ChatInviteDialog_addressSelectHeader">
Searching known users
</div>;
addressSelector = ( addressSelector = (
<AddressSelector ref={(ref) => {this.addressSelector = ref}} <AddressSelector ref={(ref) => {this.addressSelector = ref;}}
addressList={ this.state.queryList } addressList={ this.state.queryList }
onSelected={ this.onSelected } onSelected={ this.onSelected }
truncateAt={ TRUNCATE_QUERY_LIST } /> truncateAt={ TRUNCATE_QUERY_LIST }
header={ addressSelectorHeader }
/>
); );
} }
@ -409,9 +531,10 @@ module.exports = React.createClass({
<div className="mx_Dialog_title"> <div className="mx_Dialog_title">
{this.props.title} {this.props.title}
</div> </div>
<div className="mx_ChatInviteDialog_cancel" onClick={this.onCancel} > <AccessibleButton className="mx_ChatInviteDialog_cancel"
onClick={this.onCancel} >
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" /> <TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</div> </AccessibleButton>
<div className="mx_ChatInviteDialog_label"> <div className="mx_ChatInviteDialog_label">
<label htmlFor="textinput">{ this.props.description }</label> <label htmlFor="textinput">{ this.props.description }</label>
</div> </div>

View file

@ -80,8 +80,8 @@ export default class DeactivateAccountDialog extends React.Component {
let error = null; let error = null;
if (this.state.errStr) { if (this.state.errStr) {
error = <div className="error"> error = <div className="error">
{this.state.err_str} {this.state.errStr}
</div> </div>;
passwordBoxClass = 'error'; passwordBoxClass = 'error';
} }
@ -92,7 +92,7 @@ export default class DeactivateAccountDialog extends React.Component {
if (!this.state.busy) { if (!this.state.busy) {
cancelButton = <button onClick={this._onCancel} autoFocus={true}> cancelButton = <button onClick={this._onCancel} autoFocus={true}>
Cancel Cancel
</button> </button>;
} }
return ( return (

View file

@ -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', displayName: 'ErrorDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, 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() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<div className="mx_ErrorDialog" onKeyDown={ this.onKeyDown }> <BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
<div className="mx_Dialog_title"> title={this.props.title}>
{this.props.title}
</div>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{this.props.description} {this.props.description}
</div> </div>
@ -71,7 +63,7 @@ module.exports = React.createClass({
{this.props.button} {this.props.button}
</button> </button>
</div> </div>
</div> </BaseDialog>
); );
} },
}); });

View file

@ -111,20 +111,9 @@ export default React.createClass({
}); });
}, },
_onKeyDown: function(e) { _onEnterPressed: function(e) {
if (e.keyCode === 27) { // escape if (this.state.submitButtonEnabled && !this.state.busy) {
e.stopPropagation(); this._onSubmit();
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();
}
} }
}, },
@ -171,6 +160,7 @@ export default React.createClass({
render: function() { render: function() {
const Loader = sdk.getComponent("elements.Spinner"); const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let error = null; let error = null;
if (this.state.errorText) { if (this.state.errorText) {
@ -200,10 +190,11 @@ export default React.createClass({
); );
return ( return (
<div className="mx_InteractiveAuthDialog" onKeyDown={this._onKeyDown}> <BaseDialog className="mx_InteractiveAuthDialog"
<div className="mx_Dialog_title"> onEnterPressed={this._onEnterPressed}
{this.props.title} onFinished={this.props.onFinished}
</div> title={this.props.title}
>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<p>This operation requires additional authentication.</p> <p>This operation requires additional authentication.</p>
{this._renderCurrentStage()} {this._renderCurrentStage()}
@ -213,7 +204,7 @@ export default React.createClass({
{submitButton} {submitButton}
{cancelButton} {cancelButton}
</div> </div>
</div> </BaseDialog>
); );
}, },
}); });

View file

@ -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 (
<div>
<div className="mx_Dialog_content">
Sign out?
</div>
<div className="mx_Dialog_buttons" onKeyDown={ this.onKeyDown }>
<button className="mx_Dialog_primary" autoFocus onClick={this.logOut}>Sign Out</button>
<button onClick={this.cancelPrompt}>Cancel</button>
</div>
</div>
);
}
});

View file

@ -23,8 +23,9 @@ limitations under the License.
* }); * });
*/ */
var React = require("react"); import React from 'react';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
import sdk from '../../../index';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'NeedToRegisterDialog', displayName: 'NeedToRegisterDialog',
@ -54,11 +55,12 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<div className="mx_NeedToRegisterDialog"> <BaseDialog className="mx_NeedToRegisterDialog"
<div className="mx_Dialog_title"> onFinished={this.props.onFinished}
{this.props.title} title={this.props.title}
</div> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{this.props.description} {this.props.description}
</div> </div>
@ -70,7 +72,7 @@ module.exports = React.createClass({
Register Register
</button> </button>
</div> </div>
</div> </BaseDialog>
); );
} },
}); });

View file

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. 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', displayName: 'QuestionDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: React.PropTypes.string,
@ -46,25 +47,13 @@ module.exports = React.createClass({
this.props.onFinished(false); 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() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<div className="mx_QuestionDialog" onKeyDown={ this.onKeyDown }> <BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
<div className="mx_Dialog_title"> onEnterPressed={ this.onOk }
{this.props.title} title={this.props.title}
</div> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{this.props.description} {this.props.description}
</div> </div>
@ -77,7 +66,7 @@ module.exports = React.createClass({
Cancel Cancel
</button> </button>
</div> </div>
</div> </BaseDialog>
); );
} },
}); });

View file

@ -14,11 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
var React = require("react"); import React from 'react';
var sdk = require("../../../index.js"); import sdk from '../../../index';
var MatrixClientPeg = require("../../../MatrixClientPeg"); 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', displayName: 'SetDisplayNameDialog',
propTypes: { propTypes: {
onFinished: React.PropTypes.func.isRequired, onFinished: React.PropTypes.func.isRequired,
@ -42,10 +47,6 @@ module.exports = React.createClass({
this.refs.input_value.select(); this.refs.input_value.select();
}, },
getValue: function() {
return this.state.value;
},
onValueChange: function(ev) { onValueChange: function(ev) {
this.setState({ this.setState({
value: ev.target.value value: ev.target.value
@ -54,16 +55,17 @@ module.exports = React.createClass({
onFormSubmit: function(ev) { onFormSubmit: function(ev) {
ev.preventDefault(); ev.preventDefault();
this.props.onFinished(true); this.props.onFinished(true, this.state.value);
return false; return false;
}, },
render: function() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<div className="mx_SetDisplayNameDialog"> <BaseDialog className="mx_SetDisplayNameDialog"
<div className="mx_Dialog_title"> onFinished={this.props.onFinished}
Set a Display Name title="Set a Display Name"
</div> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
Your display name is how you'll appear to others when you speak in rooms.<br/> Your display name is how you'll appear to others when you speak in rooms.<br/>
What would you like it to be? What would you like it to be?
@ -79,7 +81,7 @@ module.exports = React.createClass({
<input className="mx_Dialog_primary" type="submit" value="Set" /> <input className="mx_Dialog_primary" type="submit" value="Set" />
</div> </div>
</form> </form>
</div> </BaseDialog>
); );
} },
}); });

View file

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. 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', displayName: 'TextInputDialog',
propTypes: { propTypes: {
title: React.PropTypes.string, title: React.PropTypes.string,
@ -27,7 +28,7 @@ module.exports = React.createClass({
value: React.PropTypes.string, value: React.PropTypes.string,
button: React.PropTypes.string, button: React.PropTypes.string,
focus: React.PropTypes.bool, focus: React.PropTypes.bool,
onFinished: React.PropTypes.func.isRequired onFinished: React.PropTypes.func.isRequired,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -36,7 +37,7 @@ module.exports = React.createClass({
value: "", value: "",
description: "", description: "",
button: "OK", button: "OK",
focus: true focus: true,
}; };
}, },
@ -55,25 +56,13 @@ module.exports = React.createClass({
this.props.onFinished(false); 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() { render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return ( return (
<div className="mx_TextInputDialog"> <BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
<div className="mx_Dialog_title"> onEnterPressed={this.onOk}
{this.props.title} title={this.props.title}
</div> >
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label"> <div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> {this.props.description} </label> <label htmlFor="textinput"> {this.props.description} </label>
@ -90,7 +79,7 @@ module.exports = React.createClass({
{this.props.button} {this.props.button}
</button> </button>
</div> </div>
</div> </BaseDialog>
); );
} },
}); });

View file

@ -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";

View file

@ -16,18 +16,24 @@ limitations under the License.
'use strict'; 'use strict';
var React = require("react"); import React from 'react';
var sdk = require("../../../index"); import sdk from '../../../index';
var classNames = require('classnames'); import classNames from 'classnames';
import { InviteAddressType } from './AddressTile';
module.exports = React.createClass({ export default React.createClass({
displayName: 'AddressSelector', displayName: 'AddressSelector',
propTypes: { propTypes: {
onSelected: React.PropTypes.func.isRequired, 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, truncateAt: React.PropTypes.number.isRequired,
selected: React.PropTypes.number, selected: React.PropTypes.number,
// Element to put as a header on top of the list
header: React.PropTypes.node,
}, },
getInitialState: function() { getInitialState: function() {
@ -55,7 +61,7 @@ module.exports = React.createClass({
} }
}, },
onKeyUp: function() { moveSelectionUp: function() {
if (this.state.selected > 0) { if (this.state.selected > 0) {
this.setState({ this.setState({
selected: this.state.selected - 1, 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)) { if (this.state.selected < this._maxSelected(this.props.addressList)) {
this.setState({ this.setState({
selected: this.state.selected + 1, selected: this.state.selected + 1,
@ -73,25 +79,19 @@ module.exports = React.createClass({
} }
}, },
onKeySelect: function() { chooseSelection: function() {
this.selectAddress(this.state.selected); this.selectAddress(this.state.selected);
}, },
onClick: function(index) { onClick: function(index) {
var self = this; this.selectAddress(index);
return function() {
self.selectAddress(index);
};
}, },
onMouseEnter: function(index) { onMouseEnter: function(index) {
var self = this; this.setState({
return function() { selected: index,
self.setState({ hover: true,
selected: index, });
hover: true,
});
};
}, },
onMouseLeave: function() { onMouseLeave: function() {
@ -124,8 +124,8 @@ module.exports = React.createClass({
// Saving the addressListElement so we can use it to work out, in the componentDidUpdate // Saving the addressListElement so we can use it to work out, in the componentDidUpdate
// method, how far to scroll when using the arrow keys // method, how far to scroll when using the arrow keys
addressList.push( addressList.push(
<div className={classes} onClick={this.onClick(i)} onMouseEnter={this.onMouseEnter(i)} onMouseLeave={this.onMouseLeave} key={i} ref={(ref) => { this.addressListElement = ref; }} > <div className={classes} onClick={this.onClick.bind(this, i)} onMouseEnter={this.onMouseEnter.bind(this, i)} onMouseLeave={this.onMouseLeave} key={i} ref={(ref) => { this.addressListElement = ref; }} >
<AddressTile address={this.props.addressList[i].userId} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" /> <AddressTile address={this.props.addressList[i]} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />
</div> </div>
); );
} }
@ -135,7 +135,7 @@ module.exports = React.createClass({
_maxSelected: function(list) { _maxSelected: function(list) {
var listSize = list.length === 0 ? 0 : list.length - 1; 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; return maxSelected;
}, },
@ -146,7 +146,8 @@ module.exports = React.createClass({
}); });
return ( return (
<div className={classes} ref={(ref) => {this.scrollElement = ref}}> <div className={classes} ref={(ref) => {this.scrollElement = ref;}}>
{ this.props.header }
{ this.createAddressListTiles() } { this.createAddressListTiles() }
</div> </div>
); );

View file

@ -23,16 +23,33 @@ var Invite = require("../../../Invite");
var MatrixClientPeg = require("../../../MatrixClientPeg"); var MatrixClientPeg = require("../../../MatrixClientPeg");
var Avatar = require('../../../Avatar'); 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', displayName: 'AddressTile',
propTypes: { propTypes: {
address: React.PropTypes.string.isRequired, address: InviteAddressType.isRequired,
canDismiss: React.PropTypes.bool, canDismiss: React.PropTypes.bool,
onDismissed: React.PropTypes.func, onDismissed: React.PropTypes.func,
justified: React.PropTypes.bool, justified: React.PropTypes.bool,
networkName: React.PropTypes.string,
networkUrl: React.PropTypes.string,
}, },
getDefaultProps: function() { getDefaultProps: function() {
@ -40,37 +57,30 @@ module.exports = React.createClass({
canDismiss: false, canDismiss: false,
onDismissed: function() {}, // NOP onDismissed: function() {}, // NOP
justified: false, justified: false,
networkName: "",
networkUrl: "",
}; };
}, },
render: function() { render: function() {
var userId, name, imgUrl, email; const address = this.props.address;
var BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const name = address.displayName || address.address;
var TintableSvg = sdk.getComponent("elements.TintableSvg");
// Check if the addr is a valid type let imgUrl;
var addrType = Invite.getAddressType(this.props.address); if (address.avatarMxc) {
if (addrType === "mx") { imgUrl = MatrixClientPeg.get().mxcUrlToHttp(
let user = MatrixClientPeg.get().getUser(this.props.address); address.avatarMxc, 25, 25, 'crop'
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";
} }
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; var network;
if (this.props.networkUrl !== "") { if (this.props.networkUrl !== "") {
network = ( network = (
@ -79,16 +89,20 @@ module.exports = React.createClass({
</div> </div>
); );
} }
*/
var info; const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
var error = false; const TintableSvg = sdk.getComponent("elements.TintableSvg");
if (addrType === "mx" && userId) {
var nameClasses = classNames({
"mx_AddressTile_name": true,
"mx_AddressTile_justified": this.props.justified,
});
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_id": true,
"mx_AddressTile_justified": this.props.justified, "mx_AddressTile_justified": this.props.justified,
}); });
@ -96,26 +110,34 @@ module.exports = React.createClass({
info = ( info = (
<div className="mx_AddressTile_mx"> <div className="mx_AddressTile_mx">
<div className={nameClasses}>{ name }</div> <div className={nameClasses}>{ name }</div>
<div className={idClasses}>{ userId }</div> <div className={idClasses}>{ address.address }</div>
</div> </div>
); );
} else if (addrType === "mx") { } else if (address.addressType === "mx") {
var unknownMxClasses = classNames({ const unknownMxClasses = classNames({
"mx_AddressTile_unknownMx": true, "mx_AddressTile_unknownMx": true,
"mx_AddressTile_justified": this.props.justified, "mx_AddressTile_justified": this.props.justified,
}); });
info = ( info = (
<div className={unknownMxClasses}>{ this.props.address }</div> <div className={unknownMxClasses}>{ this.props.address.address }</div>
); );
} else if (email) { } else if (address.addressType === "email") {
var emailClasses = classNames({ const emailClasses = classNames({
"mx_AddressTile_email": true, "mx_AddressTile_email": true,
"mx_AddressTile_justified": this.props.justified, "mx_AddressTile_justified": this.props.justified,
}); });
let nameNode = null;
if (address.displayName) {
nameNode = <div className={nameClasses}>{ address.displayName }</div>
}
info = ( info = (
<div className={emailClasses}>{ email }</div> <div className="mx_AddressTile_mx">
<div className={emailClasses}>{ address.address }</div>
{nameNode}
</div>
); );
} else { } else {
error = true; error = true;
@ -129,12 +151,12 @@ module.exports = React.createClass({
); );
} }
var classes = classNames({ const classes = classNames({
"mx_AddressTile": true, "mx_AddressTile": true,
"mx_AddressTile_error": error, "mx_AddressTile_error": error,
}); });
var dismiss; let dismiss;
if (this.props.canDismiss) { if (this.props.canDismiss) {
dismiss = ( dismiss = (
<div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} > <div className="mx_AddressTile_dismiss" onClick={this.props.onDismissed} >
@ -145,7 +167,6 @@ module.exports = React.createClass({
return ( return (
<div className={classes}> <div className={classes}>
{ network }
<div className="mx_AddressTile_avatar"> <div className="mx_AddressTile_avatar">
<BaseAvatar width={25} height={25} name={name} title={name} url={imgUrl} /> <BaseAvatar width={25} height={25} name={name} title={name} url={imgUrl} />
</div> </div>

View file

@ -42,14 +42,14 @@ export default React.createClass({
<div className="mx_UserSettings_cryptoSection"> <div className="mx_UserSettings_cryptoSection">
<ul> <ul>
<li><label>Device name:</label> <span>{ this.props.device.getDisplayName() }</span></li> <li><label>Device name:</label> <span>{ this.props.device.getDisplayName() }</span></li>
<li><label>Device ID:</label> <span><code>{ this.props.device.deviceId}</code></span></li> <li><label>Device ID:</label> <span><code>{ this.props.device.deviceId}</code></span></li>
<li><label>Device key:</label> <span><code><b>{ this.props.device.getFingerprint() }</b></code></span></li> <li><label>Device key:</label> <span><code><b>{ this.props.device.getFingerprint() }</b></code></span></li>
</ul> </ul>
</div> </div>
<p> <p>
If it matches, press the verify button below. If it matches, press the verify button below.
If it doesnt, then someone else is intercepting this device 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.
</p> </p>
<p> <p>
In future this verification process will be more sophisticated. In future this verification process will be more sophisticated.
@ -73,33 +73,33 @@ export default React.createClass({
); );
}, },
onBlockClick: function() { onBlacklistClick: function() {
MatrixClientPeg.get().setDeviceBlocked( MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.deviceId, true this.props.userId, this.props.device.deviceId, true
); );
}, },
onUnblockClick: function() { onUnblacklistClick: function() {
MatrixClientPeg.get().setDeviceBlocked( MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.deviceId, false this.props.userId, this.props.device.deviceId, false
); );
}, },
render: function() { render: function() {
var blockButton = null, verifyButton = null; var blacklistButton = null, verifyButton = null;
if (this.props.device.isBlocked()) { if (this.props.device.isBlocked()) {
blockButton = ( blacklistButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unblock" <button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unblacklist"
onClick={this.onUnblockClick}> onClick={this.onUnblacklistClick}>
Unblock Unblacklist
</button> </button>
); );
} else { } else {
blockButton = ( blacklistButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_block" <button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_blacklist"
onClick={this.onBlockClick}> onClick={this.onBlacklistClick}>
Block Blacklist
</button> </button>
); );
} }
@ -115,7 +115,7 @@ export default React.createClass({
verifyButton = ( verifyButton = (
<button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify" <button className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify"
onClick={this.onVerifyClick}> onClick={this.onVerifyClick}>
Verify Verify...
</button> </button>
); );
} }
@ -124,7 +124,7 @@ export default React.createClass({
return ( return (
<div className="mx_MemberDeviceInfo mx_DeviceVerifyButtons" > <div className="mx_MemberDeviceInfo mx_DeviceVerifyButtons" >
{ verifyButton } { verifyButton }
{ blockButton } { blacklistButton }
</div> </div>
); );
}, },

View file

@ -89,7 +89,7 @@ export default class DirectorySearchBox extends React.Component {
return <span className={classnames(searchbox_classes)}> return <span className={classnames(searchbox_classes)}>
<div className="mx_DirectorySearchBox_container"> <div className="mx_DirectorySearchBox_container">
<input type="text" value={this.state.value} <input type="text" name="dirsearch" value={this.state.value}
className="mx_DirectorySearchBox_input" className="mx_DirectorySearchBox_input"
ref={this._collectInput} ref={this._collectInput}
onChange={this._onChange} onKeyUp={this._onKeyUp} onChange={this._onChange} onKeyUp={this._onKeyUp}

View file

@ -57,7 +57,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
phase: this.Phases.Display, phase: this.Phases.Display,
} };
}, },
componentWillReceiveProps: function(nextProps) { componentWillReceiveProps: function(nextProps) {
@ -164,7 +164,7 @@ module.exports = React.createClass({
this.setState({ this.setState({
phase: this.Phases.Edit, phase: this.Phases.Edit,
}) });
}, },
onFocus: function(ev) { onFocus: function(ev) {
@ -197,9 +197,9 @@ module.exports = React.createClass({
sel.removeAllRanges(); sel.removeAllRanges();
if (this.props.blurToCancel) if (this.props.blurToCancel)
this.cancelEdit(); {this.cancelEdit();}
else else
this.onFinish(ev); {this.onFinish(ev);}
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
}, },

View file

@ -24,7 +24,7 @@ module.exports = React.createClass({
events: React.PropTypes.array.isRequired, events: React.PropTypes.array.isRequired,
// An array of EventTiles to render when expanded // An array of EventTiles to render when expanded
children: React.PropTypes.array.isRequired, children: React.PropTypes.array.isRequired,
// The maximum number of names to show in either the join or leave summaries // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
summaryLength: React.PropTypes.number, summaryLength: React.PropTypes.number,
// The maximum number of avatars to display in the summary // The maximum number of avatars to display in the summary
avatarsMaxLength: React.PropTypes.number, avatarsMaxLength: React.PropTypes.number,
@ -40,93 +40,229 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
summaryLength: 3, summaryLength: 1,
threshold: 3, threshold: 3,
avatarsMaxLength: 5 avatarsMaxLength: 5,
}; };
}, },
shouldComponentUpdate: function(nextProps, nextState) {
// Update if
// - The number of summarised events has changed
// - or if the summary is currently expanded
// - or if the summary is about to toggle to become collapsed
// - or if there are fewEvents, meaning the child eventTiles are shown as-is
return (
nextProps.events.length !== this.props.events.length ||
this.state.expanded || nextState.expanded ||
nextProps.events.length < this.props.threshold
);
},
_toggleSummary: function() { _toggleSummary: function() {
this.setState({ this.setState({
expanded: !this.state.expanded, expanded: !this.state.expanded,
}); });
}, },
_getEventSenderName: function(ev) { /**
if (!ev) { * Render the JSX for users aggregated by their transition sequences (`eventAggregates`) where
return 'undefined'; * the sequences are ordered by `orderedTransitionSequences`.
} * @param {object[]} eventAggregates a map of transition sequence to array of user display names
return ev.sender.name || ev.event.content.displayname || ev.getSender(); * or user IDs.
}, * @param {string[]} orderedTransitionSequences an array which is some ordering of
* `Object.keys(eventAggregates)`.
* @returns {ReactElement} a single <span> 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) { const splitTransitions = transitions.split(',');
if (events.length === 0) {
// Some neighbouring transitions are common, so canonicalise some into "pair"
// transitions
const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
// Transform into consecutive repetitions of the same transition (like 5
// consecutive 'joined_and_left's)
const coalescedTransitions = this._coalesceRepeatedTransitions(
canonicalTransitions
);
const descs = coalescedTransitions.map((t) => {
return this._getDescriptionForTransition(
t.transitionType, plural, t.repeats
);
});
const desc = this._renderCommaSeparatedList(descs);
return nameList + " " + desc;
});
if (!summaries) {
return null; return 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 = (
<span>
{joiners} joined the room
</span>
);
}
let leaveSummary = null;
if (leavers) {
leaveSummary = (
<span>
{leavers} left the room
</span>
);
}
return ( return (
<span> <span>
{joinSummary}{joinSummary && leaveSummary?'; ':''} {summaries.join(", ")}
{leaveSummary}
</span> </span>
); );
}, },
_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 (
<MemberAvatar <MemberAvatar key={m.userId} member={m} width={14} height={14} />
key={e.getId()}
member={e.sender}
width={14}
height={14}
/>
); );
}); });
return ( return (
<span> <span>
{avatars} {avatars}
@ -134,74 +270,155 @@ module.exports = React.createClass({
); );
}, },
render: function() { _getTransitionSequence: function(events) {
let summary = null; return events.map(this._getTransition);
},
// Reorder events so that joins come before leaves /**
let eventsToRender = this.props.events; * Label a given membership event, `e`, where `getContent().membership` has
* changed for each transition allowed by the Matrix protocol. This attempts to
// Filter out those who joined, then left * label the membership changes that occur in `../../../TextForEvent.js`.
let filteredEvents = eventsToRender.filter( * @param {MatrixEvent} e the membership change event to label.
(e) => { * @returns {string?} the transition type given to this event. This defaults to `null`
return eventsToRender.filter( * if a transition is not recognised.
(e2) => { */
return e.getSender() === e2.getSender() _getTransition: function(e) {
&& e.event.content.membership !== e2.event.content.membership; 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; return {
if (joinAndLeft <= 0 || joinAndLeft % 1 !== 0) { names: aggregate,
joinAndLeft = null; indices: aggregateIndices,
} };
},
let joinEvents = filteredEvents.filter((ev) => { render: function() {
return ev.event.content.membership === 'join'; 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; let expandedEvents = null;
if (expanded) { if (expanded) {
expandedEvents = this.props.children; expandedEvents = this.props.children;
} }
let avatars = this._renderAvatars(joinEvents.concat(leaveEvents)); if (fewEvents) {
return (
let toggleButton = null; <div className="mx_MemberEventListSummary">
let summaryContainer = null; {expandedEvents}
if (!fewEvents) {
summary = this._renderSummary(joinEvents, leaveEvents);
toggleButton = (
<a className="mx_MemberEventListSummary_toggle" onClick={this._toggleSummary}>
{expanded ? 'collapse' : 'expand'}
</a>
);
let plural = (joinEvents.length + leaveEvents.length > 0) ? 'others' : 'users';
let noun = (joinAndLeft === 1 ? 'user' : plural);
summaryContainer = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_MemberEventListSummary_avatars">
{avatars}
</span>
<span className="mx_TextualEvent mx_MemberEventListSummary_summary">
{summary}{joinAndLeft? '. ' + joinAndLeft + ' ' + noun + ' joined and left' : ''}
</span>&nbsp;
{toggleButton}
</div>
</div> </div>
); );
} }
// 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 = (
<a className="mx_MemberEventListSummary_toggle" onClick={this._toggleSummary}>
{expanded ? 'collapse' : 'expand'}
</a>
);
const summaryContainer = (
<div className="mx_EventTile_line">
<div className="mx_EventTile_info">
<span className="mx_MemberEventListSummary_avatars">
{avatars}
</span>
<span className="mx_TextualEvent mx_MemberEventListSummary_summary">
{summary}
</span>&nbsp;
{toggleButton}
</div>
</div>
);
return ( return (
<div className="mx_MemberEventListSummary"> <div className="mx_MemberEventListSummary">
{summaryContainer} {summaryContainer}

View file

@ -73,7 +73,7 @@ module.exports = React.createClass({
getValue: function() { getValue: function() {
var value; var value;
if (this.refs.select) { if (this.refs.select) {
value = reverseRoles[ this.refs.select.value ]; value = reverseRoles[this.refs.select.value];
if (this.refs.custom) { if (this.refs.custom) {
if (value === undefined) value = parseInt( this.refs.custom.value ); if (value === undefined) value = parseInt( this.refs.custom.value );
} }
@ -86,10 +86,10 @@ module.exports = React.createClass({
if (this.state.custom) { if (this.state.custom) {
var input; var input;
if (this.props.disabled) { if (this.props.disabled) {
input = <span>{ this.props.value }</span> input = <span>{ this.props.value }</span>;
} }
else { else {
input = <input ref="custom" type="text" size="3" defaultValue={ this.props.value } onBlur={ this.onCustomBlur } onKeyDown={ this.onCustomKeyDown }/> input = <input ref="custom" type="text" size="3" defaultValue={ this.props.value } onBlur={ this.onCustomBlur } onKeyDown={ this.onCustomKeyDown }/>;
} }
customPicker = <span> of { input }</span>; customPicker = <span> of { input }</span>;
} }
@ -115,7 +115,7 @@ module.exports = React.createClass({
<option value="Moderator">Moderator (50)</option> <option value="Moderator">Moderator (50)</option>
<option value="Admin">Admin (100)</option> <option value="Admin">Admin (100)</option>
<option value="Custom">Custom level</option> <option value="Custom">Custom level</option>
</select> </select>;
} }
return ( return (

View file

@ -69,9 +69,19 @@ var TintableSvg = React.createClass({
width={ this.props.width } width={ this.props.width }
height={ this.props.height } height={ this.props.height }
onLoad={ this.onLoad } 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; module.exports = TintableSvg;

View file

@ -56,7 +56,7 @@ module.exports = React.createClass({
<div> <div>
<ul className="mx_UserSelector_UserIdList" ref="list"> <ul className="mx_UserSelector_UserIdList" ref="list">
{this.props.selected_users.map(function(user_id, i) { {this.props.selected_users.map(function(user_id, i) {
return <li key={user_id}>{user_id} - <span onClick={function() {self.removeUser(user_id);}}>X</span></li> return <li key={user_id}>{user_id} - <span onClick={function() {self.removeUser(user_id);}}>X</span></li>;
})} })}
</ul> </ul>
<input type="text" ref="user_id_input" defaultValue="" className="mx_UserSelector_userIdInput" placeholder="ex. @bob:example.com"/> <input type="text" ref="user_id_input" defaultValue="" className="mx_UserSelector_userIdInput" placeholder="ex. @bob:example.com"/>

View file

@ -52,12 +52,24 @@ module.exports = React.createClass({
this._onCaptchaLoaded(); this._onCaptchaLoaded();
} else { } else {
console.log("Loading recaptcha script..."); console.log("Loading recaptcha script...");
var scriptTag = document.createElement('script'); window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()}; var protocol = global.location.protocol;
scriptTag.setAttribute( if (protocol === "file:") {
'src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit" var warning = document.createElement('div');
); // XXX: fix hardcoded app URL. Better solutions include:
this.refs.recaptchaContainer.appendChild(scriptTag); // * 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 <a href='https://riot.im/app'>web browser</a>.";
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) { } catch (e) {
this.setState({ this.setState({
errorText: e.toString(), errorText: e.toString(),
}) });
} }
}, },
@ -106,6 +118,7 @@ module.exports = React.createClass({
return ( return (
<div ref="recaptchaContainer"> <div ref="recaptchaContainer">
This Home Server would like to make sure you are not a robot This Home Server would like to make sure you are not a robot
<br/>
<div id={DIV_ID}></div> <div id={DIV_ID}></div>
{error} {error}
</div> </div>

View file

@ -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 // enable the submit button iff the password is non-empty
this.props.setSubmitButtonEnabled(Boolean(ev.target.value)); this.props.setSubmitButtonEnabled(Boolean(ev.target.value));
}, },
@ -209,4 +209,4 @@ export function getEntryComponentForLoginType(loginType) {
} }
} }
return FallbackAuthEntry; return FallbackAuthEntry;
}; }

View file

@ -38,6 +38,16 @@ module.exports = React.createClass({
defaultEmail: React.PropTypes.string, defaultEmail: React.PropTypes.string,
defaultUsername: React.PropTypes.string, defaultUsername: React.PropTypes.string,
defaultPassword: 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. // A username that will be used if no username is entered.
// Specifying this param will also warn the user that entering // Specifying this param will also warn the user that entering
@ -62,7 +72,8 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
fieldValid: {} fieldValid: {},
selectedTeam: null,
}; };
}, },
@ -105,10 +116,11 @@ module.exports = React.createClass({
}, },
_doSubmit: function() { _doSubmit: function() {
let email = this.refs.email.value.trim();
var promise = this.props.onRegisterClick({ var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim() || this.props.guestUsername, username: this.refs.username.value.trim() || this.props.guestUsername,
password: this.refs.password.value.trim(), password: this.refs.password.value.trim(),
email: this.refs.email.value.trim() email: email,
}); });
if (promise) { if (promise) {
@ -133,17 +145,37 @@ module.exports = React.createClass({
return true; return true;
}, },
_isUniEmail: function(email) {
return email.endsWith('.ac.uk') || email.endsWith('.edu');
},
validateField: function(field_id) { validateField: function(field_id) {
var pwd1 = this.refs.password.value.trim(); var pwd1 = this.refs.password.value.trim();
var pwd2 = this.refs.passwordConfirm.value.trim() var pwd2 = this.refs.passwordConfirm.value.trim();
switch (field_id) { switch (field_id) {
case FIELD_EMAIL: case FIELD_EMAIL:
this.markFieldValid( const email = this.refs.email.value;
field_id, if (this.props.teamsConfig && this._isUniEmail(email)) {
this.refs.email.value == '' || Email.looksValid(this.refs.email.value), const matchingTeam = this.props.teamsConfig.teams.find(
"RegistrationForm.ERR_EMAIL_INVALID" (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; break;
case FIELD_USERNAME: case FIELD_USERNAME:
// XXX: SPEC-1 // XXX: SPEC-1
@ -224,15 +256,36 @@ module.exports = React.createClass({
render: function() { render: function() {
var self = this; var self = this;
var emailSection, registerButton; var emailSection, belowEmailSection, registerButton;
if (this.props.showEmail) { if (this.props.showEmail) {
emailSection = ( emailSection = (
<input type="text" ref="email" <input type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)" autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.props.defaultEmail} defaultValue={this.props.defaultEmail}
className={this._classForField(FIELD_EMAIL, 'mx_Login_field')} className={this._classForField(FIELD_EMAIL, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_EMAIL)}} /> onBlur={function() {self.validateField(FIELD_EMAIL);}}
value={self.state.email}/>
); );
if (this.props.teamsConfig) {
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
belowEmailSection = (
<p className="mx_Login_support">
Sorry, but your university is not registered with us just yet.&nbsp;
Email us on&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail}
</a>&nbsp;
to get your university signed up. Or continue to register with Riot to enjoy our open source platform.
</p>
);
} else if (this.state.selectedTeam) {
belowEmailSection = (
<p className="mx_Login_support">
You are registering with {this.state.selectedTeam.name}
</p>
);
}
}
} }
if (this.props.onRegisterClick) { if (this.props.onRegisterClick) {
registerButton = ( registerButton = (
@ -242,31 +295,31 @@ module.exports = React.createClass({
var placeholderUserName = "User name"; var placeholderUserName = "User name";
if (this.props.guestUsername) { if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.guestUsername + ")" placeholderUserName += " (default: " + this.props.guestUsername + ")";
} }
return ( return (
<div> <div>
<form onSubmit={this.onSubmit}> <form onSubmit={this.onSubmit}>
{emailSection} {emailSection}
<br /> {belowEmailSection}
<input type="text" ref="username" <input type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername} placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
className={this._classForField(FIELD_USERNAME, 'mx_Login_field')} className={this._classForField(FIELD_USERNAME, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_USERNAME)}} /> onBlur={function() {self.validateField(FIELD_USERNAME);}} />
<br /> <br />
{ this.props.guestUsername ? { this.props.guestUsername ?
<div className="mx_Login_fieldLabel">Setting a user name will create a fresh account</div> : null <div className="mx_Login_fieldLabel">Setting a user name will create a fresh account</div> : null
} }
<input type="password" ref="password" <input type="password" ref="password"
className={this._classForField(FIELD_PASSWORD, 'mx_Login_field')} className={this._classForField(FIELD_PASSWORD, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_PASSWORD)}} onBlur={function() {self.validateField(FIELD_PASSWORD);}}
placeholder="Password" defaultValue={this.props.defaultPassword} /> placeholder="Password" defaultValue={this.props.defaultPassword} />
<br /> <br />
<input type="password" ref="passwordConfirm" <input type="password" ref="passwordConfirm"
placeholder="Confirm password" placeholder="Confirm password"
className={this._classForField(FIELD_PASSWORD_CONFIRM, 'mx_Login_field')} className={this._classForField(FIELD_PASSWORD_CONFIRM, 'mx_Login_field')}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}} onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM);}}
defaultValue={this.props.defaultPassword} /> defaultValue={this.props.defaultPassword} />
<br /> <br />
{registerButton} {registerButton}

View file

@ -67,7 +67,7 @@ module.exports = React.createClass({
configVisible: !this.props.withToggleButton || configVisible: !this.props.withToggleButton ||
(this.props.customHsUrl !== this.props.defaultHsUrl) || (this.props.customHsUrl !== this.props.defaultHsUrl) ||
(this.props.customIsUrl !== this.props.defaultIsUrl) (this.props.customIsUrl !== this.props.defaultIsUrl)
} };
}, },
onHomeserverChanged: function(ev) { onHomeserverChanged: function(ev) {

View file

@ -21,7 +21,7 @@ import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index'; import sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
export default class MAudioBody extends React.Component { export default class MAudioBody extends React.Component {
constructor(props) { constructor(props) {
@ -29,7 +29,9 @@ export default class MAudioBody extends React.Component {
this.state = { this.state = {
playing: false, playing: false,
decryptedUrl: null, decryptedUrl: null,
} decryptedBlob: null,
error: null,
};
} }
onPlayToggle() { onPlayToggle() {
this.setState({ this.setState({
@ -49,29 +51,45 @@ export default class MAudioBody extends React.Component {
componentDidMount() { componentDidMount() {
var content = this.props.mxEvent.getContent(); var content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { 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({ this.setState({
decryptedUrl: url decryptedUrl: url,
decryptedBlob: decryptedBlob,
}); });
}, (err) => { }, (err) => {
console.warn("Unable to decrypt attachment: ", err) console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image. this.setState({
this.refs.image.src = "img/warning.svg"; error: err,
});
}); });
} }
} }
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MAudioBody" ref="body">
<img src="img/warning.svg" width="16" height="16"/>
Error decrypting audio
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment // Need to decrypt the attachment
// The attachment is decrypted in componentDidMount. // 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 ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">
<img src="img/spinner.gif" ref="image" <img src="img/spinner.gif" alt={content.body} width="16" height="16"/>
alt={content.body} />
</span> </span>
); );
} }
@ -81,7 +99,7 @@ export default class MAudioBody extends React.Component {
return ( return (
<span className="mx_MAudioBody"> <span className="mx_MAudioBody">
<audio src={contentUrl} controls /> <audio src={contentUrl} controls />
<MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span> </span>
); );
} }

View file

@ -21,120 +21,332 @@ import filesize from 'filesize';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import sdk from '../../../index'; import sdk from '../../../index';
import {decryptFile} from '../../../utils/DecryptFile'; 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:
//
// <html><head><script>
// window.onmessage=function(e){eval("("+e.data.code+")")(e)}
// </script></head><body></body></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({ module.exports = React.createClass({
displayName: 'MFileBody', displayName: 'MFileBody',
getInitialState: function() { getInitialState: function() {
return { 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) { presentableTextForFile: function(content) {
var linkText = 'Attachment'; var linkText = 'Attachment';
if (content.body && content.body.length > 0) { if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body; linkText = content.body;
} }
var additionals = []; if (content.info && content.info.size) {
if (content.info) { // If we know the size of the file then add it as human readable
// if (content.info.mimetype && content.info.mimetype.length > 0) { // string to the end of the link text so that the user knows how
// additionals.push(content.info.mimetype); // big a file they are downloading.
// } // The content.info also contains a MIME-type but we don't display
if (content.info.size) { // it since it is "ugly", users generally aren't aware what it
additionals.push(filesize(content.info.size)); // means and the type of the attachment can usually be inferrered
} // from the file extension.
} linkText += ' (' + filesize(content.info.size) + ')';
if (additionals.length > 0) {
linkText += ' (' + additionals.join(', ') + ')';
} }
return linkText; return linkText;
}, },
_getContentUrl: function() { _getContentUrl: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { return MatrixClientPeg.get().mxcUrlToHttp(content.url);
return this.state.decryptedUrl;
} else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url);
}
}, },
componentDidMount: function() { componentDidMount: function() {
const content = this.props.mxEvent.getContent(); // Add this to the list of mounted components to receive notifications
if (content.file !== undefined && this.state.decryptedUrl === null) { // when the tint changes.
decryptFile(content.file).done((url) => { this.id = nextMountId++;
this.setState({ mounts[this.id] = this;
decryptedUrl: url, this.tint();
}); },
}, (err) => {
console.warn("Unable to decrypt attachment: ", err) componentWillUnmount: function() {
// Set a placeholder image when we can't decrypt the image. // Remove this from the list of mounted components
this.refs.image.src = "img/warning.svg"; 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() { render: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content); 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 (isEncrypted) {
if (content.file !== undefined && this.state.decryptedUrl === null) { 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 return (
// The attachment is decrypted in componentDidMount. <span className="mx_MFileBody" ref="body">
// For now add an img tag with a spinner. <div className="mx_MImageBody_download">
<a href="javascript:void(0)" onClick={decrypt}>
Decrypt {text}
</a>
</div>
</span>
);
}
// 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 ( return (
<span className="mx_MFileBody" ref="body"> <span className="mx_MFileBody">
<img src="img/spinner.gif" ref="image" <div className="mx_MImageBody_download">
alt={content.body} /> <div style={{display: "none"}}>
{/*
* 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.
*/}
<a ref="dummyLink"/>
</div>
<iframe src={renderer_url} onLoad={onIframeLoad} ref="iframe"/>
</div>
</span> </span>
); );
} } else if (contentUrl) {
// If the attachment is not encrypted then we check whether we
const contentUrl = this._getContentUrl(); // are being displayed in the room timeline or in a list of
// files in the right hand side of the screen.
const fileName = content.body && content.body.length > 0 ? content.body : "Attachment";
var downloadAttr = undefined;
if (this.state.decryptedUrl) {
// If the file is encrypted then we MUST download it rather than displaying it
// because Firefox is vunerable to XSS attacks in data:// URLs
// and all browsers are vunerable to XSS attacks in blob: URLs
// created with window.URL.createObjectURL
// See https://bugzilla.mozilla.org/show_bug.cgi?id=255107
// See https://w3c.github.io/FileAPI/#originOfBlobURL
//
// This is not a problem for unencrypted links because they are
// either fetched from a different domain so are safe because of
// the same-origin policy or they are fetch from the same domain,
// in which case we trust that the homeserver will set a
// Content-Security-Policy that disables script execution.
// It is reasonable to trust the homeserver in that case since
// it is the same domain that controls this javascript.
//
// We can't apply the same workaround for encrypted files because
// we can't supply HTTP headers when the user clicks on a blob:
// or data:// uri.
//
// We should probably provide a download attribute anyway 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.
downloadAttr = fileName;
}
if (contentUrl) {
if (this.props.tileShape === "file_grid") { if (this.props.tileShape === "file_grid") {
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
<div className="mx_MImageBody_download"> <div className="mx_MImageBody_download">
<a className="mx_ImageBody_downloadLink" href={contentUrl} target="_blank" rel="noopener" download={downloadAttr}> <a className="mx_ImageBody_downloadLink" href={contentUrl} target="_blank">
{ fileName } { fileName }
</a> </a>
<div className="mx_MImageBody_size"> <div className="mx_MImageBody_size">
@ -148,8 +360,8 @@ module.exports = React.createClass({
return ( return (
<span className="mx_MFileBody"> <span className="mx_MFileBody">
<div className="mx_MImageBody_download"> <div className="mx_MImageBody_download">
<a href={contentUrl} target="_blank" rel="noopener" download={downloadAttr}> <a href={contentUrl} target="_blank" rel="noopener">
<TintableSvg src="img/download.svg" width="12" height="14"/> <img src={tintedDownloadImageURL} width="12" height="14" ref="downloadImage"/>
Download {text} Download {text}
</a> </a>
</div> </div>
@ -160,7 +372,7 @@ module.exports = React.createClass({
var extra = text ? (': ' + text) : ''; var extra = text ? (': ' + text) : '';
return <span className="mx_MFileBody"> return <span className="mx_MFileBody">
Invalid file{extra} Invalid file{extra}
</span> </span>;
} }
}, },
}); });

View file

@ -23,7 +23,8 @@ import ImageUtils from '../../../ImageUtils';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import dis from '../../../dispatcher'; import dis from '../../../dispatcher';
import {decryptFile} from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MImageBody', displayName: 'MImageBody',
@ -31,11 +32,17 @@ module.exports = React.createClass({
propTypes: { propTypes: {
/* the MatrixEvent to show */ /* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired, mxEvent: React.PropTypes.object.isRequired,
/* called when the image has loaded */
onWidgetLoad: React.PropTypes.func.isRequired,
}, },
getInitialState: function() { getInitialState: function() {
return { return {
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null
}; };
}, },
@ -94,7 +101,9 @@ module.exports = React.createClass({
_getThumbUrl: function() { _getThumbUrl: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined) { if (content.file !== undefined) {
// TODO: Decrypt and use the thumbnail file if one is present. if (this.state.decryptedThumbnailUrl) {
return this.state.decryptedThumbnailUrl;
}
return this.state.decryptedUrl; return this.state.decryptedUrl;
} else { } else {
return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600); return MatrixClientPeg.get().mxcUrlToHttp(content.url, 800, 600);
@ -106,15 +115,34 @@ module.exports = React.createClass({
this.fixupHeight(); this.fixupHeight();
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
decryptFile(content.file).done((url) => { var thumbnailPromise = q(null);
this.setState({ if (content.info.thumbnail_file) {
decryptedUrl: url, thumbnailPromise = decryptFile(
content.info.thumbnail_file
).then(function(blob) {
return readBlobAsDataUri(blob);
}); });
}, (err) => { }
console.warn("Unable to decrypt attachment: ", err) var decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => {
return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(blob);
}).then((contentUrl) => {
this.setState({
decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
});
this.props.onWidgetLoad();
});
}).catch((err) => {
console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image. // Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg"; this.setState({
}); error: err,
});
}).done();
} }
}, },
@ -152,6 +180,15 @@ module.exports = React.createClass({
const TintableSvg = sdk.getComponent("elements.TintableSvg"); const TintableSvg = sdk.getComponent("elements.TintableSvg");
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MImageBody" ref="body">
<img src="img/warning.svg" width="16" height="16"/>
Error decrypting image
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment // Need to decrypt the attachment
@ -159,8 +196,15 @@ module.exports = React.createClass({
// For now add an img tag with a spinner. // For now add an img tag with a spinner.
return ( return (
<span className="mx_MImageBody" ref="body"> <span className="mx_MImageBody" ref="body">
<img className="mx_MImageBody_thumbnail" src="img/spinner.gif" ref="image" <div className="mx_MImageBody_thumbnail" ref="image" style={{
alt={content.body} /> "display": "flex",
"alignItems": "center",
"width": "100%",
}}>
<img src="img/spinner.gif" alt={content.body} width="32" height="32" style={{
"margin": "auto",
}}/>
</div>
</span> </span>
); );
} }
@ -177,7 +221,7 @@ module.exports = React.createClass({
onMouseEnter={this.onImageEnter} onMouseEnter={this.onImageEnter}
onMouseLeave={this.onImageLeave} /> onMouseLeave={this.onImageLeave} />
</a> </a>
<MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span> </span>
); );
} else if (content.body) { } else if (content.body) {

View file

@ -21,16 +21,26 @@ import MFileBody from './MFileBody';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import Model from '../../../Modal'; import Model from '../../../Modal';
import sdk from '../../../index'; import sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile, readBlobAsDataUri } from '../../../utils/DecryptFile';
import q from 'q'; import q from 'q';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MVideoBody', displayName: 'MVideoBody',
propTypes: {
/* the MatrixEvent to show */
mxEvent: React.PropTypes.object.isRequired,
/* called when the video has loaded */
onWidgetLoad: React.PropTypes.func.isRequired,
},
getInitialState: function() { getInitialState: function() {
return { return {
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
decryptedBlob: null,
error: null,
}; };
}, },
@ -83,19 +93,29 @@ module.exports = React.createClass({
if (content.info.thumbnail_file) { if (content.info.thumbnail_file) {
thumbnailPromise = decryptFile( thumbnailPromise = decryptFile(
content.info.thumbnail_file content.info.thumbnail_file
); ).then(function(blob) {
return readBlobAsDataUri(blob);
});
} }
var decryptedBlob;
thumbnailPromise.then((thumbnailUrl) => { thumbnailPromise.then((thumbnailUrl) => {
decryptFile(content.file).then((contentUrl) => { return decryptFile(content.file).then(function(blob) {
decryptedBlob = blob;
return readBlobAsDataUri(blob);
}).then((contentUrl) => {
this.setState({ this.setState({
decryptedUrl: contentUrl, decryptedUrl: contentUrl,
decryptedThumbnailUrl: thumbnailUrl, decryptedThumbnailUrl: thumbnailUrl,
decryptedBlob: decryptedBlob,
}); });
this.props.onWidgetLoad();
}); });
}).catch((err) => { }).catch((err) => {
console.warn("Unable to decrypt attachment: ", err) console.warn("Unable to decrypt attachment: ", err);
// Set a placeholder image when we can't decrypt the image. // Set a placeholder image when we can't decrypt the image.
this.refs.image.src = "img/warning.svg"; this.setState({
error: err,
});
}).done(); }).done();
} }
}, },
@ -103,14 +123,29 @@ module.exports = React.createClass({
render: function() { render: function() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (this.state.error !== null) {
return (
<span className="mx_MVideoBody" ref="body">
<img src="img/warning.svg" width="16" height="16"/>
Error decrypting video
</span>
);
}
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
// Need to decrypt the attachment // Need to decrypt the attachment
// The attachment is decrypted in componentDidMount. // The attachment is decrypted in componentDidMount.
// For now add an img tag with a spinner. // For now add an img tag with a spinner.
return ( return (
<span className="mx_MImageBody" ref="body"> <span className="mx_MVideoBody" ref="body">
<img className="mx_MImageBody_thumbnail" src="img/spinner.gif" ref="image" <div className="mx_MImageBody_thumbnail" ref="image" style={{
alt={content.body} /> "display": "flex",
"align-items": "center",
"justify-items": "center",
"width": "100%",
}}>
<img src="img/spinner.gif" alt={content.body} width="16" height="16"/>
</div>
</span> </span>
); );
} }
@ -141,7 +176,7 @@ module.exports = React.createClass({
controls preload={preload} autoPlay={false} controls preload={preload} autoPlay={false}
height={height} width={width} poster={poster}> height={height} width={width} poster={poster}>
</video> </video>
<MFileBody {...this.props} decryptedUrl={this.state.decryptedUrl} /> <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
</span> </span>
); );
}, },

View file

@ -27,7 +27,6 @@ var sdk = require('../../../index');
var ScalarAuthClient = require("../../../ScalarAuthClient"); var ScalarAuthClient = require("../../../ScalarAuthClient");
var Modal = require("../../../Modal"); var Modal = require("../../../Modal");
var SdkConfig = require('../../../SdkConfig'); var SdkConfig = require('../../../SdkConfig');
var UserSettingsStore = require('../../../UserSettingsStore');
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -201,7 +200,7 @@ module.exports = React.createClass({
global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId()); global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId());
} }
}, },
} };
}, },
onStarterLinkClick: function(starterLink, ev) { onStarterLinkClick: function(starterLink, ev) {
@ -213,16 +212,6 @@ module.exports = React.createClass({
// which requires the user to click through and THEN we can open the link in a new tab because // which requires the user to click through and THEN we can open the link in a new tab because
// the window.open command occurs in the same stack frame as the onClick callback. // the window.open command occurs in the same stack frame as the onClick callback.
let integrationsEnabled = UserSettingsStore.isFeatureEnabled("integration_management");
if (!integrationsEnabled) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, {
title: "Integrations disabled",
description: "You need to enable the Labs option 'Integrations Management' in your Riot user settings first.",
});
return;
}
// Go fetch a scalar token // Go fetch a scalar token
let scalarClient = new ScalarAuthClient(); let scalarClient = new ScalarAuthClient();
scalarClient.connect().then(() => { scalarClient.connect().then(() => {
@ -241,8 +230,8 @@ module.exports = React.createClass({
if (!confirmed) { if (!confirmed) {
return; return;
} }
let width = window.screen.width > 1024 ? 1024 : window.screen.width; let width = window.screen.width > 1024 ? 1024 : window.screen.width;
let height = window.screen.height > 800 ? 800 : window.screen.height; let height = window.screen.height > 800 ? 800 : window.screen.height;
let left = (window.screen.width - width) / 2; let left = (window.screen.width - width) / 2;
let top = (window.screen.height - height) / 2; let top = (window.screen.height - height) / 2;
window.open(completeUrl, '_blank', `height=${height}, width=${width}, top=${top}, left=${left},`); window.open(completeUrl, '_blank', `height=${height}, width=${width}, top=${top}, left=${left},`);
@ -304,4 +293,3 @@ module.exports = React.createClass({
} }
}, },
}); });

View file

@ -103,13 +103,13 @@ module.exports = React.createClass({
} }
if (oldCanonicalAlias !== this.state.canonicalAlias) { if (oldCanonicalAlias !== this.state.canonicalAlias) {
console.log("AliasSettings: Updating canonical alias"); console.log("AliasSettings: Updating canonical alias");
promises = [ q.all(promises).then( promises = [q.all(promises).then(
MatrixClientPeg.get().sendStateEvent( MatrixClientPeg.get().sendStateEvent(
this.props.roomId, "m.room.canonical_alias", { this.props.roomId, "m.room.canonical_alias", {
alias: this.state.canonicalAlias alias: this.state.canonicalAlias
}, "" }, ""
) )
) ]; )];
} }
return promises; return promises;
@ -281,7 +281,7 @@ module.exports = React.createClass({
onValueChanged={ self.onAliasChanged.bind(self, localDomain, i) } onValueChanged={ self.onAliasChanged.bind(self, localDomain, i) }
editable={ self.props.canSetAliases } editable={ self.props.canSetAliases }
initialValue={ alias } /> initialValue={ alias } />
<div className="mx_RoomSettings_deleteAlias"> <div className="mx_RoomSettings_deleteAlias mx_filterFlipColor">
{ deleteButton } { deleteButton }
</div> </div>
</div> </div>
@ -297,7 +297,7 @@ module.exports = React.createClass({
placeholder={ "New address (e.g. #foo:" + localDomain + ")" } placeholder={ "New address (e.g. #foo:" + localDomain + ")" }
blurToCancel={ false } blurToCancel={ false }
onValueChanged={ self.onAliasAdded } /> onValueChanged={ self.onAliasAdded } />
<div className="mx_RoomSettings_addAlias"> <div className="mx_RoomSettings_addAlias mx_filterFlipColor">
<img src="img/plus.svg" width="14" height="14" alt="Add" <img src="img/plus.svg" width="14" height="14" alt="Add"
onClick={ self.onAliasAdded.bind(self, undefined) }/> onClick={ self.onAliasAdded.bind(self, undefined) }/>
</div> </div>

View file

@ -135,7 +135,7 @@ module.exports = React.createClass({
</div> </div>
); );
} }
var boundClick = this._onColorSchemeChanged.bind(this, i) var boundClick = this._onColorSchemeChanged.bind(this, i);
return ( return (
<div className="mx_RoomSettings_roomColor" <div className="mx_RoomSettings_roomColor"
key={ "room_color_" + i } key={ "room_color_" + i }

View file

@ -121,13 +121,13 @@ module.exports = React.createClass({
onChange={ this.onGlobalDisableUrlPreviewChange } onChange={ this.onGlobalDisableUrlPreviewChange }
checked={ this.state.globalDisableUrlPreview } /> checked={ this.state.globalDisableUrlPreview } />
Disable URL previews by default for participants in this room Disable URL previews by default for participants in this room
</label> </label>;
} }
else { else {
disableRoomPreviewUrls = disableRoomPreviewUrls =
<label> <label>
URL previews are { this.state.globalDisableUrlPreview ? "disabled" : "enabled" } by default for participants in this room. URL previews are { this.state.globalDisableUrlPreview ? "disabled" : "enabled" } by default for participants in this room.
</label> </label>;
} }
return ( return (

View file

@ -93,8 +93,8 @@ module.exports = React.createClass({
} }
else { else {
joinText = (<span> joinText = (<span>
Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice')}} Join as <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice');}}
href="#">voice</a> or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video') }} href="#">voice</a> or <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video'); }}
href="#">video</a>. href="#">video</a>.
</span>); </span>);

Some files were not shown because too many files have changed in this diff Show more