Merge remote-tracking branch 'origin/develop' into rav/hotkey-ux

This commit is contained in:
Richard van der Hoff 2017-01-24 20:47:24 +00:00
commit 6dd46d532a
124 changed files with 1837 additions and 654 deletions

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

71
.eslintrc.js Normal file
View file

@ -0,0 +1,71 @@
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"],
"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

@ -4,3 +4,6 @@ node_js:
install: install:
- npm install - npm install
- (cd node_modules/matrix-js-sdk && npm install) - (cd node_modules/matrix-js-sdk && npm install)
script:
- npm run test
- ./.travis-test-riot.sh

View file

@ -1,3 +1,30 @@
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) 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) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.8.3...v0.8.4)

View file

@ -19,7 +19,7 @@ npm install
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

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "0.8.4", "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",
@ -58,7 +59,7 @@
"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", "commonmark": "^0.27.0",
"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",
@ -67,13 +68,14 @@
"react-dom": "^15.4.0", "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",
@ -85,9 +87,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",

View file

@ -56,5 +56,5 @@ module.exports = {
} }
return 'img/' + images[total % images.length] + '.png'; return 'img/' + images[total % images.length] + '.png';
} }
} };

View file

@ -159,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) {

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

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

View file

@ -55,29 +55,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

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

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);
}
};
var parsed = this.parser.parse(this.input);
var rendered = this.renderer.render(parsed);
this.renderer.paragraph = real_paragraph;
return rendered;
} }
const real_options = Object.assign({}, marked_options, { toPlaintext() {
renderer: real_renderer, const real_paragraph = this.renderer.paragraph;
});
const real_parser = new marked.Parser(real_options); // The default `out` function only sends the input through an XML
return real_parser.parse(this._copyTokens()); // 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,6 +19,55 @@ 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';
/**
* 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 />;
}
},
});
let _counter = 0;
module.exports = { module.exports = {
DialogContainerId: "mx_Dialog_Container", DialogContainerId: "mx_Dialog_Container",
@ -36,20 +85,46 @@ module.exports = {
}, },
createDialog: function(Element, props, className) { createDialog: function(Element, props, className) {
var self = this; 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: function(loader, props, className) {
var self = this;
// never call this via modal.close() from onFinished() otherwise it will loop // never call this via modal.close() from onFinished() otherwise it will loop
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()); ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
}; };
// don't attempt to reuse the same AsyncWrapper for different dialogs,
// otherwise we'll get confused.
const modalCount = _counter++;
// FIXME: If a dialog uses getDefaultProps it clobbers the onFinished // 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!
var dialog = ( var dialog = (
<div className={"mx_Dialog_wrapper " + className}> <div className={"mx_Dialog_wrapper " + className}>
<div className="mx_Dialog"> <div className="mx_Dialog">
<Element {...props} onFinished={closeDialog}/> <AsyncWrapper key={modalCount} loader={loader} {...props} onFinished={closeDialog}/>
</div> </div>
<div className="mx_Dialog_background" onClick={ closeDialog.bind(this, false) }></div> <div className="mx_Dialog_background" onClick={ closeDialog.bind(this, false) }></div>
</div> </div>

View file

@ -88,7 +88,7 @@ var Notifier = {
if (e) { if (e) {
e.load(); e.load();
e.play(); e.play();
}; }
}, },
start: function() { start: function() {

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];
} }

View file

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

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

@ -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,7 +331,7 @@ 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() {}));

View file

@ -254,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();
@ -386,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,7 +149,7 @@ 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. // List of functions to call when the tint changes.
@ -185,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;
@ -194,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];
@ -210,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");
@ -224,6 +225,19 @@ module.exports = {
}); });
}, },
tintSvgWhite: function(whiteColor) {
if (!whiteColor) {
whiteColor = colors[3];
}
if (colors[3] === whiteColor) {
return;
}
colors[3] = whiteColor;
tintables.forEach(function(tintable) {
tintable();
});
},
// XXX: we could just move this all into TintableSvg, but as it's so similar // XXX: we could just move this all into TintableSvg, but as it's so similar
// to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg) // to the CSS fixup stuff in Tinter (just that the fixups are stored in TintableSvg)
// keeping it here for now. // keeping it here for now.

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

@ -46,4 +46,4 @@ module.exports = {
return names.join(', ') + ' and ' + lastPerson + ' are typing'; return names.join(', ') + ' and ' + lastPerson + ' are typing';
} }
} }
} };

View file

@ -0,0 +1,84 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption';
export default React.createClass({
displayName: 'ExportE2eKeysDialog',
getInitialState: function() {
return {
collectedPassword: false,
};
},
_onPassphraseFormSubmit: function(ev) {
ev.preventDefault();
console.log(this.refs.passphrase1.value);
return false;
},
render: function() {
let content;
if (!this.state.collectedPassword) {
content = (
<div className="mx_Dialog_content">
<p>
This process will allow you to export the keys for messages
you have received in encrypted rooms to a local file. You
will then be able to import the file into another Matrix
client in the future, so that client will also be able to
decrypt these messages.
</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>
<form onSubmit={this._onPassphraseFormSubmit}>
<div className="mx_TextInputDialog_label">
<label htmlFor="passphrase1">Enter passphrase</label>
</div>
<div>
<input ref="passphrase1" id="passphrase1"
className="mx_TextInputDialog_input"
autoFocus={true} size="64" type="password"/>
</div>
<div className="mx_Dialog_buttons">
<input className="mx_Dialog_primary" type="submit" value="Export" />
</div>
</form>
</div>
);
}
return (
<div className="mx_exportE2eKeysDialog">
<div className="mx_Dialog_title">
Export room keys
</div>
{content}
</div>
);
},
});

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

@ -75,8 +75,6 @@ import views$dialogs$ChatInviteDialog from './components/views/dialogs/ChatInvit
views$dialogs$ChatInviteDialog && (module.exports.components['views.dialogs.ChatInviteDialog'] = views$dialogs$ChatInviteDialog); 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';

View file

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

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

@ -160,8 +160,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:
@ -170,28 +170,28 @@ 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}
/> />;
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.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

@ -77,7 +77,7 @@ module.exports = React.createClass({
getChildContext: function() { getChildContext: function() {
return { return {
appConfig: this.props.config, appConfig: this.props.config,
} };
}, },
getInitialState: function() { getInitialState: function() {
@ -456,6 +456,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;
@ -586,6 +589,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
*/ */
@ -687,6 +734,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) {
@ -979,7 +1036,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');
@ -1003,6 +1060,7 @@ module.exports = React.createClass({
defaultHsUrl={this.getDefaultHsUrl()} defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()} defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand} brand={this.props.config.brand}
teamsConfig={this.props.config.teamsConfig}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
registrationUrl={this.props.registrationUrl} registrationUrl={this.props.registrationUrl}

View file

@ -19,7 +19,7 @@ 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; const MILLIS_IN_DAY = 86400000;

View file

@ -19,6 +19,14 @@ 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 TYPING_AVATARS_LIMIT = 2;
const HIDE_DEBOUNCE_MS = 10000;
const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomStatusBar', displayName: 'RoomStatusBar',
@ -60,6 +68,13 @@ 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,
}, },
getInitialState: function() { getInitialState: function() {
@ -78,6 +93,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() {
@ -104,35 +131,28 @@ module.exports = React.createClass({
}); });
}, },
// determine if we need to call onResize // We don't need the actual height - just whether it is likely to have
_checkForResize: function(prevProps, prevState) {
// figure out the old height and the new height of the status bar. We
// don't need the actual height - just whether it is likely to have
// changed - so we use '0' to indicate normal size, and other values to // changed - so we use '0' to indicate normal size, and other values to
// indicate other sizes. // indicate other sizes.
var oldSize, newSize; _getSize: function(state, props) {
if (state.syncState === "ERROR" ||
if (prevState.syncState === "ERROR") { state.whoisTypingString ||
oldSize = 1; props.numUnreadMessages ||
} else if (prevProps.tabCompleteEntries) { !props.atEndOfLiveTimeline ||
oldSize = 0; props.hasActiveCall) {
} else if (prevProps.hasUnsentMessages) { return STATUS_BAR_EXPANDED;
oldSize = 2; } else if (props.tabCompleteEntries) {
} else { return STATUS_BAR_HIDDEN;
oldSize = 0; } else if (props.hasUnsentMessages) {
return STATUS_BAR_EXPANDED_LARGE;
} }
return STATUS_BAR_HIDDEN;
},
if (this.state.syncState === "ERROR") { // determine if we need to call onResize
newSize = 1; _checkForResize: function(prevProps, prevState) {
} else if (this.props.tabCompleteEntries) { // figure out the old height and the new height of the status bar.
newSize = 0; return this._getSize(prevProps, prevState) !== this._getSize(this.props, this.state);
} 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 +193,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(TYPING_AVATARS_LIMIT)}
<span>.</span>
<span>.</span>
</div> </div>
); );
} }
@ -184,6 +202,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

@ -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");
@ -758,7 +761,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;
@ -962,7 +965,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,
@ -1025,7 +1028,7 @@ module.exports = React.createClass({
if (scrollPanel) { if (scrollPanel) {
scrollPanel.checkScroll(); scrollPanel.checkScroll();
} }
} };
var lastRoomId; var lastRoomId;
@ -1090,7 +1093,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) {
@ -1099,7 +1102,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
} }
@ -1208,8 +1211,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();
@ -1331,6 +1335,18 @@ module.exports = React.createClass({
// no longer anything to do here // no longer anything to do here
}, },
onStatusBarVisible: function() {
this.setState({
statusBarVisible: true,
});
},
onStatusBarHidden: function() {
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) {
@ -1498,7 +1514,7 @@ module.exports = React.createClass({
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');
@ -1513,7 +1529,9 @@ 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}
/>;
} }
var aux = null; var aux = null;
@ -1569,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
@ -1597,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 =
@ -1614,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
@ -1667,6 +1685,10 @@ module.exports = React.createClass({
</div> </div>
); );
} }
let statusBarAreaClass = "mx_RoomView_statusArea mx_fadable";
if (this.state.statusBarVisible) {
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">
@ -1689,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

@ -600,7 +600,7 @@ 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("Saved scroll state", this.scrollState);
return; return;
} }

View file

@ -564,9 +564,10 @@ 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;
} }
@ -644,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();
} }
@ -821,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);
@ -843,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();
@ -930,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);

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 = {

View file

@ -33,6 +33,53 @@ var AccessibleButton = require('../views/elements/AccessibleButton');
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',
@ -94,6 +141,12 @@ module.exports = React.createClass({
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() {
@ -294,8 +347,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,
@ -343,34 +396,20 @@ module.exports = React.createClass({
_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() }
{ SETTINGS_LABELS.map( this._renderSyncedSetting ) }
{ THEMES.map( this._renderThemeSelector ) }
</div>
</div>
);
},
_renderUrlPreviewSelector: function() {
return <div className="mx_UserSettings_toggle">
<input id="urlPreviewsDisabled" <input id="urlPreviewsDisabled"
type="checkbox" type="checkbox"
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() } defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
@ -379,22 +418,44 @@ module.exports = React.createClass({
<label htmlFor="urlPreviewsDisabled"> <label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default Disable inline URL previews by default
</label> </label>
</div> </div>;
</div> },
{ settingsLabels.forEach( setting => {
<div className="mx_UserSettings_toggle"> _renderSyncedSetting: function(setting) {
return <div className="mx_UserSettings_toggle" key={ setting.id }>
<input id={ setting.id } <input id={ setting.id }
type="checkbox" type="checkbox"
defaultChecked={ syncedSettings[setting.id] } defaultChecked={ this._syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) } onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/> />
<label htmlFor={ setting.id }> <label htmlFor={ setting.id }>
{ settings.label } { setting.label }
</label> </label>
</div> </div>;
})} },
</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() {
@ -461,7 +522,7 @@ module.exports = React.createClass({
{features} {features}
</div> </div>
</div> </div>
) );
}, },
_renderDeactivateAccount: function() { _renderDeactivateAccount: function() {
@ -545,10 +606,10 @@ module.exports = React.createClass({
<label htmlFor={id}>{this.nameForMedium(val.medium)}</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>
<div className="mx_UserSettings_threepidButton"> <div className="mx_UserSettings_threepidButton mx_filterFlipColor">
<img src="img/icon_context_delete.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} /> <img src="img/cancel-small.svg" width="14" height="14" alt="Remove" onClick={this.onRemoveThreepidClicked.bind(this, val)} />
</div> </div>
</div> </div>
); );
@ -570,7 +631,7 @@ module.exports = React.createClass({
blurToCancel={ false } blurToCancel={ false }
onValueChanged={ this.onAddThreepidClicked } /> onValueChanged={ this.onAddThreepidClicked } />
</div> </div>
<div className="mx_UserSettings_threepidButton"> <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>
@ -651,7 +712,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>

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) {
@ -129,7 +129,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:' &&
@ -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

@ -49,6 +49,21 @@ module.exports = React.createClass({
email: React.PropTypes.string, email: React.PropTypes.string,
username: React.PropTypes.string, username: React.PropTypes.string,
guestAccessToken: React.PropTypes.string, guestAccessToken: React.PropTypes.string,
teamsConfig: React.PropTypes.shape({
// Email address to request new teams
supportEmail: React.PropTypes.string,
teams: React.PropTypes.arrayOf(React.PropTypes.shape({
// The displayed name of the team
"name": React.PropTypes.string,
// The suffix with which every team email address ends
"emailSuffix": React.PropTypes.string,
// The rooms to use during auto-join
"rooms": React.PropTypes.arrayOf(React.PropTypes.shape({
"id": React.PropTypes.string,
"autoJoin": React.PropTypes.bool,
})),
})).required,
}),
defaultDeviceDisplayName: React.PropTypes.string, defaultDeviceDisplayName: React.PropTypes.string,
@ -169,6 +184,26 @@ module.exports = React.createClass({
accessToken: response.access_token accessToken: response.access_token
}); });
// Auto-join rooms
if (self.props.teamsConfig && self.props.teamsConfig.teams) {
for (let i = 0; i < self.props.teamsConfig.teams.length; i++) {
let team = self.props.teamsConfig.teams[i];
if (self.state.formVals.email.endsWith(team.emailSuffix)) {
console.log("User successfully registered with team " + team.name);
if (!team.rooms) {
break;
}
team.rooms.forEach((room) => {
if (room.autoJoin) {
console.log("Auto-joining " + room.id);
MatrixClientPeg.get().joinRoom(room.id);
}
});
break;
}
}
}
if (self.props.brand) { if (self.props.brand) {
MatrixClientPeg.get().getPushers().done((resp)=>{ MatrixClientPeg.get().getPushers().done((resp)=>{
var pushers = resp.pushers; var pushers = resp.pushers;
@ -254,6 +289,7 @@ module.exports = React.createClass({
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.props.teamsConfig}
guestUsername={this.props.username} guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
@ -297,7 +333,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

@ -42,7 +42,7 @@ module.exports = React.createClass({
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
defaultToInitialLetter: true defaultToInitialLetter: true
} };
}, },
getInitialState: function() { getInitialState: function() {

View file

@ -42,7 +42,7 @@ module.exports = React.createClass({
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
viewUserOnClick: false, viewUserOnClick: false,
} };
}, },
getInitialState: function() { getInitialState: function() {
@ -64,7 +64,7 @@ module.exports = React.createClass({
props.width, props.width,
props.height, props.height,
props.resizeMethod) props.resizeMethod)
} };
}, },
render: function() { render: function() {
@ -78,7 +78,7 @@ module.exports = React.createClass({
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

@ -28,6 +28,15 @@ var AccessibleButton = require('../elements/AccessibleButton');
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: {
@ -72,15 +81,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) {
@ -120,15 +126,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();
@ -136,21 +142,16 @@ module.exports = React.createClass({
} else if (e.keyCode === 13) { // enter } else if (e.keyCode === 13) { // enter
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (this.refs.textinput.value == '') {
// if there's nothing in the input box, submit the form
this.onButtonClick(); 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 });
}
} }
}, },
@ -180,7 +181,7 @@ module.exports = React.createClass({
inviteList: inviteList, inviteList: inviteList,
queryList: [], queryList: [],
}); });
} };
}, },
onClick: function(index) { onClick: function(index) {
@ -316,13 +317,18 @@ 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:
// * 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 true;
} }
}
return false; return false;
}, },
@ -362,6 +368,22 @@ module.exports = React.createClass({
return addrs; return addrs;
}, },
_addInputToList: function() {
const addrType = Invite.getAddressType(this.refs.textinput.value);
if (addrType !== null) {
const inviteList = this.state.inviteList.slice();
inviteList.push(this.refs.textinput.value.trim());
this.setState({
inviteList: inviteList,
queryList: [],
});
return inviteList;
} else {
this.setState({ error: true });
return null;
}
},
render: function() { render: function() {
var TintableSvg = sdk.getComponent("elements.TintableSvg"); var TintableSvg = sdk.getComponent("elements.TintableSvg");
var AddressSelector = sdk.getComponent("elements.AddressSelector"); var AddressSelector = sdk.getComponent("elements.AddressSelector");
@ -395,13 +417,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 }
/>
); );
} }

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

@ -28,6 +28,9 @@ module.exports = React.createClass({
addressList: React.PropTypes.array.isRequired, addressList: React.PropTypes.array.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 +58,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 +67,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 +76,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() {
self.setState({
selected: index, selected: index,
hover: true, hover: true,
}); });
};
}, },
onMouseLeave: function() { onMouseLeave: function() {
@ -124,7 +121,7 @@ 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].userId} justified={true} networkName="vector" networkUrl="img/search-icon-vector.svg" />
</div> </div>
); );
@ -135,7 +132,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 +143,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

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

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

@ -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,7 +52,7 @@ module.exports = React.createClass({
this._onCaptchaLoaded(); this._onCaptchaLoaded();
} else { } else {
console.log("Loading recaptcha script..."); console.log("Loading recaptcha script...");
window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded()}; window.mx_on_recaptcha_loaded = () => {this._onCaptchaLoaded();};
var protocol = global.location.protocol; var protocol = global.location.protocol;
if (protocol === "file:") { if (protocol === "file:") {
var warning = document.createElement('div'); var warning = document.createElement('div');
@ -101,7 +101,7 @@ module.exports = React.createClass({
} catch (e) { } catch (e) {
this.setState({ this.setState({
errorText: e.toString(), errorText: e.toString(),
}) });
} }
}, },

View file

@ -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 suffix with which every team email address ends
"emailSuffix": 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,14 @@ module.exports = React.createClass({
}, },
_doSubmit: function() { _doSubmit: function() {
let email = this.refs.email.value.trim();
if (this.state.selectedTeam) {
email += "@" + this.state.selectedTeam.emailSuffix;
}
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) {
@ -119,6 +134,25 @@ module.exports = React.createClass({
} }
}, },
onSelectTeam: function(teamIndex) {
let team = this._getSelectedTeam(teamIndex);
if (team) {
this.refs.email.value = this.refs.email.value.split("@")[0];
}
this.setState({
selectedTeam: team,
showSupportEmail: teamIndex === "other",
});
},
_getSelectedTeam: function(teamIndex) {
if (this.props.teamsConfig &&
this.props.teamsConfig.teams[teamIndex]) {
return this.props.teamsConfig.teams[teamIndex];
}
return null;
},
/** /**
* Returns true if all fields were valid last time * Returns true if all fields were valid last time
* they were validated. * they were validated.
@ -135,15 +169,19 @@ module.exports = React.createClass({
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( let email = this.refs.email.value;
field_id, if (this.props.teamsConfig) {
this.refs.email.value == '' || Email.looksValid(this.refs.email.value), let team = this.state.selectedTeam;
"RegistrationForm.ERR_EMAIL_INVALID" if (team) {
); email = email + "@" + team.emailSuffix;
}
}
let 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
@ -222,17 +260,64 @@ module.exports = React.createClass({
return cls; return cls;
}, },
_renderEmailInputSuffix: function() {
let suffix = null;
if (!this.state.selectedTeam) {
return suffix;
}
let team = this.state.selectedTeam;
if (team) {
suffix = "@" + team.emailSuffix;
}
return suffix;
},
render: function() { render: function() {
var self = this; var self = this;
var emailSection, registerButton; var emailSection, teamSection, teamAdditionSupport, registerButton;
if (this.props.showEmail) { if (this.props.showEmail) {
let emailSuffix = this._renderEmailInputSuffix();
emailSection = ( emailSection = (
<div>
<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}/>
{emailSuffix ? <input className="mx_Login_field" value={emailSuffix} disabled/> : null }
</div>
); );
if (this.props.teamsConfig) {
teamSection = (
<select
defaultValue="-1"
className="mx_Login_field"
onBlur={function() {self.validateField(FIELD_EMAIL);}}
onChange={function(ev) {self.onSelectTeam(ev.target.value);}}
>
<option key="-1" value="-1">No team</option>
{this.props.teamsConfig.teams.map((t, index) => {
return (
<option key={index} value={index}>
{t.name}
</option>
);
})}
<option key="-2" value="other">Other</option>
</select>
);
if (this.props.teamsConfig.supportEmail && this.state.showSupportEmail) {
teamAdditionSupport = (
<span>
If your team is not listed, email&nbsp;
<a href={"mailto:" + this.props.teamsConfig.supportEmail}>
{this.props.teamsConfig.supportEmail}
</a>
</span>
);
}
}
} }
if (this.props.onRegisterClick) { if (this.props.onRegisterClick) {
registerButton = ( registerButton = (
@ -242,31 +327,34 @@ 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}>
{teamSection}
{teamAdditionSupport}
<br />
{emailSection} {emailSection}
<br /> <br />
<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

@ -31,7 +31,7 @@ export default class MAudioBody extends React.Component {
decryptedUrl: null, decryptedUrl: null,
decryptedBlob: null, decryptedBlob: null,
error: null, error: null,
} };
} }
onPlayToggle() { onPlayToggle() {
this.setState({ this.setState({

View file

@ -281,7 +281,7 @@ module.exports = React.createClass({
decryptedBlob: blob, decryptedBlob: blob,
}); });
}).catch((err) => { }).catch((err) => {
console.warn("Unable to decrypt attachment: ", err) console.warn("Unable to decrypt attachment: ", err);
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
description: "Error decrypting attachment" description: "Error decrypting attachment"
}); });
@ -372,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

@ -111,7 +111,7 @@ module.exports = React.createClass({
this.props.onWidgetLoad(); 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.setState({ this.setState({
error: err, error: err,

View file

@ -200,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) {

View file

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

View file

@ -259,11 +259,11 @@ module.exports = WithMatrixClient(React.createClass({
onEditClicked: function(e) { onEditClicked: function(e) {
var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
var buttonRect = e.target.getBoundingClientRect() var buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page // The window X and Y offsets are to adjust position when zoomed in to page
var x = buttonRect.right + window.pageXOffset; var x = buttonRect.right + window.pageXOffset;
var y = (buttonRect.top + (e.target.height / 2) + window.pageYOffset) - 19; var y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
var self = this; var self = this;
ContextualMenu.createMenu(MessageContextMenu, { ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10, chevronOffset: 10,
@ -293,7 +293,7 @@ module.exports = WithMatrixClient(React.createClass({
// If it is, we want to display the complete date along with the HH:MM:SS, // If it is, we want to display the complete date along with the HH:MM:SS,
// rather than just HH:MM:SS. // rather than just HH:MM:SS.
let dayAfterEvent = new Date(this.props.mxEvent.getTs()); let dayAfterEvent = new Date(this.props.mxEvent.getTs());
dayAfterEvent.setDate(dayAfterEvent.getDate() + 1) dayAfterEvent.setDate(dayAfterEvent.getDate() + 1);
dayAfterEvent.setHours(0); dayAfterEvent.setHours(0);
dayAfterEvent.setMinutes(0); dayAfterEvent.setMinutes(0);
dayAfterEvent.setSeconds(0); dayAfterEvent.setSeconds(0);
@ -366,10 +366,11 @@ module.exports = WithMatrixClient(React.createClass({
}, },
onCryptoClicked: function(e) { onCryptoClicked: function(e) {
var EncryptedEventDialog = sdk.getComponent("dialogs.EncryptedEventDialog");
var event = this.props.mxEvent; var event = this.props.mxEvent;
Modal.createDialog(EncryptedEventDialog, { Modal.createDialogAsync((cb) => {
require(['../../../async-components/views/dialogs/EncryptedEventDialog'], cb);
}, {
event: event, event: event,
}); });
}, },
@ -465,7 +466,7 @@ module.exports = WithMatrixClient(React.createClass({
} }
var editButton = ( var editButton = (
<img className="mx_EventTile_editButton" src="img/icon_context_message.svg" width="19" height="19" alt="Options" title="Options" onClick={this.onEditClicked} /> <span className="mx_EventTile_editButton" title="Options" onClick={this.onEditClicked} />
); );
var e2e; var e2e;

View file

@ -60,13 +60,15 @@ module.exports = React.createClass({
}, },
componentDidMount: function() { componentDidMount: function() {
if (this.refs.description) if (this.refs.description) {
linkifyElement(this.refs.description, linkifyMatrix.options); linkifyElement(this.refs.description, linkifyMatrix.options);
}
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
if (this.refs.description) if (this.refs.description) {
linkifyElement(this.refs.description, linkifyMatrix.options); linkifyElement(this.refs.description, linkifyMatrix.options);
}
}, },
componentWillUnmount: function() { componentWillUnmount: function() {
@ -116,7 +118,7 @@ module.exports = React.createClass({
if (image) { if (image) {
img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}> img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={ image } onClick={ this.onImageClick }/> <img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={ image } onClick={ this.onImageClick }/>
</div> </div>;
} }
return ( return (

View file

@ -60,7 +60,7 @@ export default class MemberDeviceInfo extends React.Component {
</div> </div>
); );
} }
}; }
MemberDeviceInfo.displayName = 'MemberDeviceInfo'; MemberDeviceInfo.displayName = 'MemberDeviceInfo';
MemberDeviceInfo.propTypes = { MemberDeviceInfo.propTypes = {

View file

@ -65,7 +65,7 @@ module.exports = WithMatrixClient(React.createClass({
updating: 0, updating: 0,
devicesLoading: true, devicesLoading: true,
devices: null, devices: null,
} };
}, },
componentWillMount: function() { componentWillMount: function() {
@ -203,7 +203,7 @@ module.exports = WithMatrixClient(React.createClass({
} }
var cancelled = false; var cancelled = false;
this._cancelDeviceList = function() { cancelled = true; } this._cancelDeviceList = function() { cancelled = true; };
var client = this.props.matrixClient; var client = this.props.matrixClient;
var self = this; var self = this;
@ -621,7 +621,7 @@ module.exports = WithMatrixClient(React.createClass({
<img src="img/create-big.svg" width="26" height="26" /> <img src="img/create-big.svg" width="26" height="26" />
</div> </div>
<div className={labelClasses}><i>Start new chat</i></div> <div className={labelClasses}><i>Start new chat</i></div>
</AccessibleButton> </AccessibleButton>;
startChat = <div> startChat = <div>
<h3>Direct chats</h3> <h3>Direct chats</h3>
@ -655,7 +655,7 @@ module.exports = WithMatrixClient(React.createClass({
var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator"; var giveOpLabel = this.state.isTargetMod ? "Revoke Moderator" : "Make Moderator";
giveModButton = <AccessibleButton className="mx_MemberInfo_field" onClick={this.onModToggle}> giveModButton = <AccessibleButton className="mx_MemberInfo_field" onClick={this.onModToggle}>
{giveOpLabel} {giveOpLabel}
</AccessibleButton> </AccessibleButton>;
} }
// TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet // TODO: we should have an invite button if this MemberInfo is showing a user who isn't actually in the current room yet
@ -673,7 +673,7 @@ module.exports = WithMatrixClient(React.createClass({
{banButton} {banButton}
{giveModButton} {giveModButton}
</div> </div>
</div> </div>;
} }
const memberName = this.props.member.name; const memberName = this.props.member.name;

View file

@ -32,7 +32,7 @@ var SHARE_HISTORY_WARNING =
Newly invited users will see the history of this room. <br/> Newly invited users will see the history of this room. <br/>
If you'd prefer invited users not to see messages that were sent before they joined, <br/> If you'd prefer invited users not to see messages that were sent before they joined, <br/>
turn off, 'Share message history with new users' in the settings for this room. turn off, 'Share message history with new users' in the settings for this room.
</span> </span>;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MemberList', displayName: 'MemberList',
@ -338,8 +338,8 @@ module.exports = React.createClass({
} }
memberList.push( memberList.push(
<EntityTile key={e.getStateKey()} name={e.getContent().display_name} /> <EntityTile key={e.getStateKey()} name={e.getContent().display_name} />
) );
}) });
} }
} }

View file

@ -46,7 +46,7 @@ module.exports = React.createClass({
(this.user_last_modified_time === undefined || (this.user_last_modified_time === undefined ||
this.user_last_modified_time < nextProps.member.user.getLastModifiedTime()) this.user_last_modified_time < nextProps.member.user.getLastModifiedTime())
) { ) {
return true return true;
} }
return false; return false;
}, },

View file

@ -222,20 +222,22 @@ export default class MessageComposer extends React.Component {
</div> </div>
); );
let e2eimg, e2etitle; let e2eImg, e2eTitle, e2eClass;
if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) { if (MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId)) {
// FIXME: show a /!\ if there are untrusted devices in the room... // FIXME: show a /!\ if there are untrusted devices in the room...
e2eimg = 'img/e2e-verified.svg'; e2eImg = 'img/e2e-verified.svg';
e2etitle = 'Encrypted room'; e2eTitle = 'Encrypted room';
e2eClass = 'mx_MessageComposer_e2eIcon';
} else { } else {
e2eimg = 'img/e2e-unencrypted.svg'; e2eImg = 'img/e2e-unencrypted.svg';
e2etitle = 'Unencrypted room'; e2eTitle = 'Unencrypted room';
e2eClass = 'mx_MessageComposer_e2eIcon mx_filterFlipColor';
} }
controls.push( controls.push(
<img key="e2eIcon" className="mx_MessageComposer_e2eIcon" src={e2eimg} width="12" height="12" <img key="e2eIcon" className={e2eClass} src={e2eImg} width="12" height="12"
alt={e2etitle} title={e2etitle} alt={e2eTitle} title={e2eTitle}
/> />
); );
var callButton, videoCallButton, hangupButton; var callButton, videoCallButton, hangupButton;
@ -331,6 +333,7 @@ export default class MessageComposer extends React.Component {
const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name;
const className = classNames("mx_MessageComposer_format_button", { const className = classNames("mx_MessageComposer_format_button", {
mx_MessageComposer_format_button_disabled: disabled, mx_MessageComposer_format_button_disabled: disabled,
mx_filterFlipColor: true,
}); });
return <img className={className} return <img className={className}
title={name} title={name}
@ -355,11 +358,11 @@ export default class MessageComposer extends React.Component {
<div style={{flex: 1}}></div> <div style={{flex: 1}}></div>
<img title={`Turn Markdown ${this.state.inputState.isRichtextEnabled ? 'on' : 'off'}`} <img title={`Turn Markdown ${this.state.inputState.isRichtextEnabled ? 'on' : 'off'}`}
onMouseDown={this.onToggleMarkdownClicked} onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown" className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} /> src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} />
<img title="Hide Text Formatting Toolbar" <img title="Hide Text Formatting Toolbar"
onClick={this.onToggleFormattingClicked} onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel" className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src="img/icon-text-cancel.svg" /> src="img/icon-text-cancel.svg" />
</div> </div>
</div>: null </div>: null
@ -367,7 +370,7 @@ export default class MessageComposer extends React.Component {
</div> </div>
); );
} }
}; }
MessageComposer.propTypes = { MessageComposer.propTypes = {
tabComplete: React.PropTypes.any, tabComplete: React.PropTypes.any,

View file

@ -443,12 +443,12 @@ export default class MessageComposerInput extends React.Component {
selection = this.state.editorState.getSelection(); selection = this.state.editorState.getSelection();
let modifyFn = { let modifyFn = {
bold: text => `**${text}**`, 'bold': text => `**${text}**`,
italic: text => `*${text}*`, 'italic': text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* 'underline': text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
strike: text => `~~${text}~~`, 'strike': text => `~~${text}~~`,
code: text => `\`${text}\``, 'code': text => `\`${text}\``,
blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''), 'blockquote': text => text.split('\n').map(line => `> ${line}\n`).join(''),
'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''), 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''), 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
}[command]; }[command];
@ -462,8 +462,9 @@ export default class MessageComposerInput extends React.Component {
} }
} }
if (newState == null) if (newState == null) {
newState = RichUtils.handleKeyCommand(this.state.editorState, command); newState = RichUtils.handleKeyCommand(this.state.editorState, command);
}
if (newState != null) { if (newState != null) {
this.setEditorState(newState); this.setEditorState(newState);
@ -523,7 +524,9 @@ export default class MessageComposerInput extends React.Component {
); );
} else { } else {
const md = new Markdown(contentText); const md = new Markdown(contentText);
if (!md.isPlainText()) { if (md.isPlainText()) {
contentText = md.toPlaintext();
} else {
contentHTML = md.toHTML(); contentHTML = md.toHTML();
} }
} }
@ -663,7 +666,7 @@ export default class MessageComposerInput extends React.Component {
const blockName = { const blockName = {
'code-block': 'code', 'code-block': 'code',
blockquote: 'quote', 'blockquote': 'quote',
'unordered-list-item': 'bullet', 'unordered-list-item': 'bullet',
'ordered-list-item': 'numbullet', 'ordered-list-item': 'numbullet',
}; };
@ -716,7 +719,7 @@ export default class MessageComposerInput extends React.Component {
selection={selection} /> selection={selection} />
</div> </div>
<div className={className}> <div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator" <img className="mx_MessageComposer_input_markdownIndicator mx_filterFlipColor"
onMouseDown={this.onMarkdownToggleClicked} onMouseDown={this.onMarkdownToggleClicked}
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`} title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} /> src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
@ -738,7 +741,7 @@ export default class MessageComposerInput extends React.Component {
</div> </div>
); );
} }
}; }
MessageComposerInput.propTypes = { MessageComposerInput.propTypes = {
tabComplete: React.PropTypes.any, tabComplete: React.PropTypes.any,

View file

@ -331,6 +331,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText);
} }
else { else {
const contentText = mdown.toPlaintext();
sendMessagePromise = isEmote ? sendMessagePromise = isEmote ?
MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) :
MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText);

View file

@ -71,7 +71,7 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
leftOffset: 0, leftOffset: 0,
} };
}, },
getInitialState: function() { getInitialState: function() {
@ -81,7 +81,7 @@ module.exports = React.createClass({
// position. // position.
return { return {
suppressDisplay: !this.props.suppressAnimation, suppressDisplay: !this.props.suppressAnimation,
} };
}, },
componentWillUnmount: function() { componentWillUnmount: function() {

View file

@ -183,8 +183,8 @@ module.exports = React.createClass({
'm.room.name', user_id 'm.room.name', user_id
); );
save_button = <AccessibleButton className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save</AccessibleButton> save_button = <AccessibleButton className="mx_RoomHeader_textButton" onClick={this.props.onSaveClick}>Save</AccessibleButton>;
cancel_button = <AccessibleButton className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </AccessibleButton> cancel_button = <AccessibleButton className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </AccessibleButton>;
} }
if (this.props.saving) { if (this.props.saving) {
@ -194,7 +194,7 @@ module.exports = React.createClass({
if (can_set_room_name) { if (can_set_room_name) {
var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor"); var RoomNameEditor = sdk.getComponent("rooms.RoomNameEditor");
name = <RoomNameEditor ref="nameEditor" room={this.props.room} /> name = <RoomNameEditor ref="nameEditor" room={this.props.room} />;
} }
else { else {
var searchStatus; var searchStatus;
@ -233,7 +233,7 @@ module.exports = React.createClass({
if (can_set_room_topic) { if (can_set_room_topic) {
var RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor"); var RoomTopicEditor = sdk.getComponent("rooms.RoomTopicEditor");
topic_el = <RoomTopicEditor ref="topicEditor" room={this.props.room} /> topic_el = <RoomTopicEditor ref="topicEditor" room={this.props.room} />;
} else { } else {
var topic; var topic;
if (this.props.room) { if (this.props.room) {
@ -302,7 +302,11 @@ module.exports = React.createClass({
rightPanel_buttons = rightPanel_buttons =
<AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title="<"> <AccessibleButton className="mx_RoomHeader_button" onClick={this.onShowRhsClick} title="<">
<TintableSvg src="img/minimise.svg" width="10" height="16"/> <TintableSvg src="img/minimise.svg" width="10" height="16"/>
<<<<<<< HEAD
</AccessibleButton> </AccessibleButton>
=======
</div>;
>>>>>>> origin/develop
} }
var right_row; var right_row;

View file

@ -46,7 +46,7 @@ module.exports = React.createClass({
isLoadingLeftRooms: false, isLoadingLeftRooms: false,
lists: {}, lists: {},
incomingCall: null, incomingCall: null,
} };
}, },
componentWillMount: function() { componentWillMount: function() {
@ -338,7 +338,7 @@ module.exports = React.createClass({
// as this is used to calculate the CSS fixed top position for the stickies // as this is used to calculate the CSS fixed top position for the stickies
var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height; var scrollAreaHeight = ReactDOM.findDOMNode(this).getBoundingClientRect().height;
var top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset) var top = (incomingCallBox.parentElement.getBoundingClientRect().top + window.pageYOffset);
// Make sure we don't go too far up, if the headers aren't sticky // Make sure we don't go too far up, if the headers aren't sticky
top = (top < scrollAreaOffset) ? scrollAreaOffset : top; top = (top < scrollAreaOffset) ? scrollAreaOffset : top;
// make sure we don't go too far down, if the headers aren't sticky // make sure we don't go too far down, if the headers aren't sticky
@ -401,7 +401,7 @@ module.exports = React.createClass({
var stickyHeight = sticky.dataset.originalHeight; var stickyHeight = sticky.dataset.originalHeight;
var stickyHeader = sticky.childNodes[0]; var stickyHeader = sticky.childNodes[0];
var topStuckHeight = stickyHeight * i; var topStuckHeight = stickyHeight * i;
var bottomStuckHeight = stickyHeight * (stickyWrappers.length - i) var bottomStuckHeight = stickyHeight * (stickyWrappers.length - i);
if (self.scrollAreaSufficient && stickyPosition < (scrollArea.scrollTop + topStuckHeight)) { if (self.scrollAreaSufficient && stickyPosition < (scrollArea.scrollTop + topStuckHeight)) {
// Top stickies // Top stickies
@ -520,7 +520,7 @@ module.exports = React.createClass({
collapsed={ self.props.collapsed } collapsed={ self.props.collapsed }
searchFilter={ self.props.searchFilter } searchFilter={ self.props.searchFilter }
onHeaderClick={ self.onSubListHeaderClick } onHeaderClick={ self.onSubListHeaderClick }
onShowMoreRooms={ self.onShowMoreRooms } /> onShowMoreRooms={ self.onShowMoreRooms } />;
} }
}) } }) }

View file

@ -58,7 +58,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
busy: false busy: false
} };
}, },
componentWillMount: function() { componentWillMount: function() {
@ -96,7 +96,7 @@ module.exports = React.createClass({
emailMatchBlock = <div className="error"> emailMatchBlock = <div className="error">
Unable to ascertain that the address this invite was Unable to ascertain that the address this invite was
sent to matches one associated with your account. sent to matches one associated with your account.
</div> </div>;
} else if (this.state.invitedEmailMxid != MatrixClientPeg.get().credentials.userId) { } else if (this.state.invitedEmailMxid != MatrixClientPeg.get().credentials.userId) {
emailMatchBlock = emailMatchBlock =
<div className="mx_RoomPreviewBar_warning"> <div className="mx_RoomPreviewBar_warning">
@ -107,7 +107,7 @@ module.exports = React.createClass({
This invitation was sent to <b><span className="email">{this.props.invitedEmail}</span></b>, which is not associated with this account.<br/> This invitation was sent to <b><span className="email">{this.props.invitedEmail}</span></b>, which is not associated with this account.<br/>
You may wish to login with a different account, or add this email to this account. You may wish to login with a different account, or add this email to this account.
</div> </div>
</div> </div>;
} }
} }
joinBlock = ( joinBlock = (

View file

@ -404,7 +404,7 @@ module.exports = React.createClass({
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState; var roomState = this.props.room.currentState;
return (roomState.mayClientSendStateEvent("m.room.join_rules", cli) && return (roomState.mayClientSendStateEvent("m.room.join_rules", cli) &&
roomState.mayClientSendStateEvent("m.room.guest_access", cli)) roomState.mayClientSendStateEvent("m.room.guest_access", cli));
}, },
onManageIntegrations(ev) { onManageIntegrations(ev) {
@ -510,7 +510,7 @@ module.exports = React.createClass({
var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings"); var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings");
var EditableText = sdk.getComponent('elements.EditableText'); var EditableText = sdk.getComponent('elements.EditableText');
var PowerSelector = sdk.getComponent('elements.PowerSelector'); var PowerSelector = sdk.getComponent('elements.PowerSelector');
var Loader = sdk.getComponent("elements.Spinner") var Loader = sdk.getComponent("elements.Spinner");
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState; var roomState = this.props.room.currentState;
@ -557,7 +557,7 @@ module.exports = React.createClass({
</div>; </div>;
} }
else { else {
userLevelsSection = <div>No users have specific privileges in this room.</div> userLevelsSection = <div>No users have specific privileges in this room.</div>;
} }
var banned = this.props.room.getMembersWithMembership("ban"); var banned = this.props.room.getMembersWithMembership("ban");
@ -635,7 +635,7 @@ module.exports = React.createClass({
</label>); </label>);
})) : (self.state.tags && self.state.tags.join) ? self.state.tags.join(", ") : "" })) : (self.state.tags && self.state.tags.join) ? self.state.tags.join(", ") : ""
} }
</div> </div>;
} }
// If there is no history_visibility, it is assumed to be 'shared'. // If there is no history_visibility, it is assumed to be 'shared'.
@ -653,7 +653,7 @@ module.exports = React.createClass({
addressWarning = addressWarning =
<div className="mx_RoomSettings_warning"> <div className="mx_RoomSettings_warning">
To link to a room it must have <a href="#addresses">an address</a>. To link to a room it must have <a href="#addresses">an address</a>.
</div> </div>;
} }
var inviteGuestWarning; var inviteGuestWarning;
@ -664,7 +664,7 @@ module.exports = React.createClass({
this.setState({ join_rule: "invite", guest_access: "can_join" }); this.setState({ join_rule: "invite", guest_access: "can_join" });
e.preventDefault(); e.preventDefault();
}}>Click here to fix</a>. }}>Click here to fix</a>.
</div> </div>;
} }
var integrationsButton; var integrationsButton;

View file

@ -27,6 +27,7 @@ var ContextualMenu = require('../../structures/ContextualMenu');
var RoomNotifs = require('../../../RoomNotifs'); var RoomNotifs = require('../../../RoomNotifs');
var FormattingUtils = require('../../../utils/FormattingUtils'); var FormattingUtils = require('../../../utils/FormattingUtils');
var AccessibleButton = require('../elements/AccessibleButton'); var AccessibleButton = require('../elements/AccessibleButton');
var UserSettingsStore = require('../../../UserSettingsStore');
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomTile', displayName: 'RoomTile',
@ -177,7 +178,8 @@ module.exports = React.createClass({
var self = this; var self = this;
ContextualMenu.createMenu(RoomTagMenu, { ContextualMenu.createMenu(RoomTagMenu, {
chevronOffset: 10, chevronOffset: 10,
menuColour: "#FFFFFF", // XXX: fix horrid hardcoding
menuColour: UserSettingsStore.getSyncedSettings().theme === 'dark' ? "#2d2d2d" : "#FFFFFF",
left: x, left: x,
top: y, top: y,
room: this.props.room, room: this.props.room,
@ -220,7 +222,7 @@ module.exports = React.createClass({
var avatarContainerClasses = classNames({ var avatarContainerClasses = classNames({
'mx_RoomTile_avatar_container': true, 'mx_RoomTile_avatar_container': true,
'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu, 'mx_RoomTile_avatar_roomTagMenu': this.state.roomTagMenu,
}) });
var badgeClasses = classNames({ var badgeClasses = classNames({
'mx_RoomTile_badge': true, 'mx_RoomTile_badge': true,

View file

@ -135,8 +135,8 @@ var SearchableEntityList = React.createClass({
<form onSubmit={this.onQuerySubmit} autoComplete="off"> <form onSubmit={this.onQuerySubmit} autoComplete="off">
<input className="mx_SearchableEntityList_query" id="mx_SearchableEntityList_query" type="text" <input className="mx_SearchableEntityList_query" id="mx_SearchableEntityList_query" type="text"
onChange={this.onQueryChanged} value={this.state.query} onChange={this.onQueryChanged} value={this.state.query}
onFocus= {() => { this.setState({ focused: true }) }} onFocus= {() => { this.setState({ focused: true }); }}
onBlur= {() => { this.setState({ focused: false }) }} onBlur= {() => { this.setState({ focused: false }); }}
placeholder={this.props.searchPlaceholderText} /> placeholder={this.props.searchPlaceholderText} />
</form> </form>
); );

View file

@ -45,7 +45,7 @@ module.exports = React.createClass({
var cancelButton; var cancelButton;
if (this.props.onCancelClick) { if (this.props.onCancelClick) {
cancelButton = <AccessibleButton className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </AccessibleButton> cancelButton = <AccessibleButton className="mx_RoomHeader_cancelButton" onClick={this.props.onCancelClick}><img src="img/cancel.svg" width="18" height="18" alt="Cancel"/> </AccessibleButton>;
} }
var showRhsButton; var showRhsButton;
@ -71,4 +71,3 @@ module.exports = React.createClass({
); );
}, },
}); });

View file

@ -49,7 +49,7 @@ module.exports = React.createClass({
return { return {
avatarUrl: this.props.initialAvatarUrl, avatarUrl: this.props.initialAvatarUrl,
phase: this.Phases.Display, phase: this.Phases.Display,
} };
}, },
componentWillReceiveProps: function(newProps) { componentWillReceiveProps: function(newProps) {
@ -120,7 +120,7 @@ module.exports = React.createClass({
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ? // XXX: FIXME: once we track in the JS what our own displayname is(!) then use it here rather than ?
avatarImg = <BaseAvatar width={this.props.width} height={this.props.height} resizeMethod='crop' avatarImg = <BaseAvatar width={this.props.width} height={this.props.height} resizeMethod='crop'
name='?' idName={ MatrixClientPeg.get().getUserIdLocalpart() } url={this.state.avatarUrl} /> name='?' idName={ MatrixClientPeg.get().getUserIdLocalpart() } url={this.state.avatarUrl} />;
} }
var uploadSection; var uploadSection;

View file

@ -60,7 +60,7 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
phase: this.Phases.Edit phase: this.Phases.Edit
} };
}, },
changePassword: function(old_password, new_password) { changePassword: function(old_password, new_password) {
@ -106,7 +106,7 @@ module.exports = React.createClass({
render: function() { render: function() {
var rowClassName = this.props.rowClassName; var rowClassName = this.props.rowClassName;
var rowLabelClassName = this.props.rowLabelClassName; var rowLabelClassName = this.props.rowLabelClassName;
var rowInputClassName = this.props.rowInputClassName var rowInputClassName = this.props.rowInputClassName;
var buttonClassName = this.props.buttonClassName; var buttonClassName = this.props.buttonClassName;
switch (this.state.phase) { switch (this.state.phase) {

View file

@ -88,7 +88,7 @@ export default class DevicesPanel extends React.Component {
const removed_id = device.device_id; const removed_id = device.device_id;
this.setState((state, props) => { this.setState((state, props) => {
const newDevices = state.devices.filter( const newDevices = state.devices.filter(
d => { return d.device_id != removed_id } d => { return d.device_id != removed_id; }
); );
return { devices: newDevices }; return { devices: newDevices };
}); });
@ -98,7 +98,7 @@ export default class DevicesPanel extends React.Component {
var DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry'); var DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry');
return ( return (
<DevicesPanelEntry key={device.device_id} device={device} <DevicesPanelEntry key={device.device_id} device={device}
onDeleted={()=>{this._onDeviceDeleted(device)}} /> onDeleted={()=>{this._onDeviceDeleted(device);}} />
); );
} }

View file

@ -15,12 +15,9 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import q from 'q';
import sdk from '../../../index'; import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import DateUtils from '../../../DateUtils';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
export default class DevicesPanelEntry extends React.Component { export default class DevicesPanelEntry extends React.Component {
@ -61,7 +58,7 @@ export default class DevicesPanelEntry extends React.Component {
if (this._unmounted) { return; } if (this._unmounted) { return; }
if (error.httpStatus !== 401 || !error.data || !error.data.flows) { if (error.httpStatus !== 401 || !error.data || !error.data.flows) {
// doesn't look like an interactive-auth failure // doesn't look like an interactive-auth failure
throw e; throw error;
} }
// pop up an interactive auth dialog // pop up an interactive auth dialog
@ -121,7 +118,7 @@ export default class DevicesPanelEntry extends React.Component {
let deleteButton; let deleteButton;
if (this.state.deleteError) { if (this.state.deleteError) {
deleteButton = <div className="error">{this.state.deleteError}</div> deleteButton = <div className="error">{this.state.deleteError}</div>;
} else { } else {
deleteButton = ( deleteButton = (
<div className="mx_textButton" <div className="mx_textButton"

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict'; 'use strict';
var React = require("react"); var React = require("react");
var Notifier = require("../../../Notifier"); var Notifier = require("../../../Notifier");
var sdk = require('../../../index');
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
module.exports = React.createClass({ module.exports = React.createClass({

View file

@ -45,7 +45,7 @@ function createRoom(opts) {
Modal.createDialog(NeedToRegisterDialog, { Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register", title: "Please Register",
description: "Guest users can't create new rooms. Please register to create room and start a chat." description: "Guest users can't create new rooms. Please register to create room and start a chat."
}) });
}, 0); }, 0);
return q(null); return q(null);
} }
@ -78,7 +78,7 @@ function createRoom(opts) {
let modal; let modal;
setTimeout(()=>{ setTimeout(()=>{
modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner') modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
}, 0); }, 0);
let roomId; let roomId;

View file

@ -39,8 +39,11 @@ class MatrixDispatcher extends flux.Dispatcher {
setTimeout(super.dispatch.bind(this, payload), 0); setTimeout(super.dispatch.bind(this, payload), 0);
} }
} }
}; }
// XXX this is a big anti-pattern, and makes testing hard. Because dispatches
// happen asynchronously, it is possible for actions dispatched in one thread
// to arrive in another, with *hilarious* consequences.
if (global.mxDispatcher === undefined) { if (global.mxDispatcher === undefined) {
global.mxDispatcher = new MatrixDispatcher(); global.mxDispatcher = new MatrixDispatcher();
} }

View file

@ -23,4 +23,4 @@ module.exports = function(dest, src) {
} }
} }
return dest; return dest;
} };

View file

@ -28,3 +28,27 @@ module.exports.getComponent = function(componentName) {
return Skinner.getComponent(componentName); return Skinner.getComponent(componentName);
}; };
/* hacky functions for megolm import/export until we give it a UI */
import * as MegolmExportEncryption from './utils/MegolmExportEncryption';
import MatrixClientPeg from './MatrixClientPeg';
window.exportKeys = function(password) {
return MatrixClientPeg.get().exportRoomKeys().then((k) => {
return MegolmExportEncryption.encryptMegolmKeyFile(
JSON.stringify(k), password
);
}).then((f) => {
console.log(new TextDecoder().decode(new Uint8Array(f)));
}).done();
};
window.importKeys = function(password, data) {
const arrayBuffer = new TextEncoder().encode(data).buffer;
return MegolmExportEncryption.decryptMegolmKeyFile(
arrayBuffer, password
).then((j) => {
const k = JSON.parse(j);
return MatrixClientPeg.get().importRoomKeys(k);
});
};

View file

@ -0,0 +1,319 @@
/*
Copyright 2017 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
"use strict";
// polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8';
let TextEncoder = window.TextEncoder;
if (!TextEncoder) {
TextEncoder = TextEncodingUtf8.TextEncoder;
}
let TextDecoder = window.TextDecoder;
if (!TextDecoder) {
TextDecoder = TextEncodingUtf8.TextDecoder;
}
const subtleCrypto = window.crypto.subtle || window.crypto.webkitSubtle;
/**
* Decrypt a megolm key file
*
* @param {ArrayBuffer} file
* @param {String} password
* @return {Promise<String>} promise for decrypted output
*/
export function decryptMegolmKeyFile(data, password) {
const body = unpackMegolmKeyFile(data);
// check we have a version byte
if (body.length < 1) {
throw new Error('Invalid file: too short');
}
const version = body[0];
if (version !== 1) {
throw new Error('Unsupported version');
}
const ciphertextLength = body.length-(1+16+16+4+32);
if (body.length < 0) {
throw new Error('Invalid file: too short');
}
const salt = body.subarray(1, 1+16);
const iv = body.subarray(17, 17+16);
const iterations = body[33] << 24 | body[34] << 16 | body[35] << 8 | body[36];
const ciphertext = body.subarray(37, 37+ciphertextLength);
const hmac = body.subarray(-32);
return deriveKeys(salt, iterations, password).then((keys) => {
const [aes_key, hmac_key] = keys;
const toVerify = body.subarray(0, -32);
return subtleCrypto.verify(
{name: 'HMAC'},
hmac_key,
hmac,
toVerify,
).then((isValid) => {
if (!isValid) {
throw new Error('Authentication check failed: incorrect password?');
}
return subtleCrypto.decrypt(
{
name: "AES-CTR",
counter: iv,
length: 64,
},
aes_key,
ciphertext,
);
});
}).then((plaintext) => {
return new TextDecoder().decode(new Uint8Array(plaintext));
});
}
/**
* Encrypt a megolm key file
*
* @param {String} data
* @param {String} password
* @param {Object=} options
* @param {Nunber=} options.kdf_rounds Number of iterations to perform of the
* key-derivation function.
* @return {Promise<ArrayBuffer>} promise for encrypted output
*/
export function encryptMegolmKeyFile(data, password, options) {
options = options || {};
const kdf_rounds = options.kdf_rounds || 100000;
const salt = new Uint8Array(16);
window.crypto.getRandomValues(salt);
// clear bit 63 of the salt to stop us hitting the 64-bit counter boundary
// (which would mean we wouldn't be able to decrypt on Android). The loss
// of a single bit of salt is a price we have to pay.
salt[9] &= 0x7f;
const iv = new Uint8Array(16);
window.crypto.getRandomValues(iv);
return deriveKeys(salt, kdf_rounds, password).then((keys) => {
const [aes_key, hmac_key] = keys;
return subtleCrypto.encrypt(
{
name: "AES-CTR",
counter: iv,
length: 64,
},
aes_key,
new TextEncoder().encode(data),
).then((ciphertext) => {
const cipherArray = new Uint8Array(ciphertext);
const bodyLength = (1+salt.length+iv.length+4+cipherArray.length+32);
const resultBuffer = new Uint8Array(bodyLength);
let idx = 0;
resultBuffer[idx++] = 1; // version
resultBuffer.set(salt, idx); idx += salt.length;
resultBuffer.set(iv, idx); idx += iv.length;
resultBuffer[idx++] = kdf_rounds >> 24;
resultBuffer[idx++] = (kdf_rounds >> 16) & 0xff;
resultBuffer[idx++] = (kdf_rounds >> 8) & 0xff;
resultBuffer[idx++] = kdf_rounds & 0xff;
resultBuffer.set(cipherArray, idx); idx += cipherArray.length;
const toSign = resultBuffer.subarray(0, idx);
return subtleCrypto.sign(
{name: 'HMAC'},
hmac_key,
toSign,
).then((hmac) => {
hmac = new Uint8Array(hmac);
resultBuffer.set(hmac, idx);
return packMegolmKeyFile(resultBuffer);
});
});
});
}
/**
* Derive the AES and HMAC-SHA-256 keys for the file
*
* @param {Unit8Array} salt salt for pbkdf
* @param {Number} iterations number of pbkdf iterations
* @param {String} password password
* @return {Promise<[CryptoKey, CryptoKey]>} promise for [aes key, hmac key]
*/
function deriveKeys(salt, iterations, password) {
return subtleCrypto.importKey(
'raw',
new TextEncoder().encode(password),
{name: 'PBKDF2'},
false,
['deriveBits']
).then((key) => {
return subtleCrypto.deriveBits(
{
name: 'PBKDF2',
salt: salt,
iterations: iterations,
hash: 'SHA-512',
},
key,
512
);
}).then((keybits) => {
const aes_key = keybits.slice(0, 32);
const hmac_key = keybits.slice(32);
const aes_prom = subtleCrypto.importKey(
'raw',
aes_key,
{name: 'AES-CTR'},
false,
['encrypt', 'decrypt']
);
const hmac_prom = subtleCrypto.importKey(
'raw',
hmac_key,
{
name: 'HMAC',
hash: {name: 'SHA-256'},
},
false,
['sign', 'verify']
);
return Promise.all([aes_prom, hmac_prom]);
});
}
const HEADER_LINE = '-----BEGIN MEGOLM SESSION DATA-----';
const TRAILER_LINE = '-----END MEGOLM SESSION DATA-----';
/**
* Unbase64 an ascii-armoured megolm key file
*
* Strips the header and trailer lines, and unbase64s the content
*
* @param {ArrayBuffer} data input file
* @return {Uint8Array} unbase64ed content
*/
function unpackMegolmKeyFile(data) {
// parse the file as a great big String. This should be safe, because there
// should be no non-ASCII characters, and it means that we can do string
// comparisons to find the header and footer, and feed it into window.atob.
const fileStr = new TextDecoder().decode(new Uint8Array(data));
// look for the start line
let lineStart = 0;
while (1) {
const lineEnd = fileStr.indexOf('\n', lineStart);
if (lineEnd < 0) {
throw new Error('Header line not found');
}
const line = fileStr.slice(lineStart, lineEnd).trim();
// start the next line after the newline
lineStart = lineEnd+1;
if (line === HEADER_LINE) {
break;
}
}
const dataStart = lineStart;
// look for the end line
while (1) {
const lineEnd = fileStr.indexOf('\n', lineStart);
const line = fileStr.slice(lineStart, lineEnd < 0 ? undefined : lineEnd)
.trim();
if (line === TRAILER_LINE) {
break;
}
if (lineEnd < 0) {
throw new Error('Trailer line not found');
}
// start the next line after the newline
lineStart = lineEnd+1;
}
const dataEnd = lineStart;
return decodeBase64(fileStr.slice(dataStart, dataEnd));
}
/**
* ascii-armour a megolm key file
*
* base64s the content, and adds header and trailer lines
*
* @param {Uint8Array} data raw data
* @return {ArrayBuffer} formatted file
*/
function packMegolmKeyFile(data) {
// we split into lines before base64ing, because encodeBase64 doesn't deal
// terribly well with large arrays.
const LINE_LENGTH = (72 * 4 / 3);
const nLines = Math.ceil(data.length / LINE_LENGTH);
const lines = new Array(nLines + 3);
lines[0] = HEADER_LINE;
let o = 0;
let i;
for (i = 1; i <= nLines; i++) {
lines[i] = encodeBase64(data.subarray(o, o+LINE_LENGTH));
o += LINE_LENGTH;
}
lines[i++] = TRAILER_LINE;
lines[i] = '';
return (new TextEncoder().encode(lines.join('\n'))).buffer;
}
/**
* Encode a typed array of uint8 as base64.
* @param {Uint8Array} uint8Array The data to encode.
* @return {string} The base64.
*/
function encodeBase64(uint8Array) {
// Misinterpt the Uint8Array as Latin-1.
// window.btoa expects a unicode string with codepoints in the range 0-255.
var latin1String = String.fromCharCode.apply(null, uint8Array);
// Use the builtin base64 encoder.
return window.btoa(latin1String);
}
/**
* Decode a base64 string to a typed array of uint8.
* @param {string} base64 The base64 to decode.
* @return {Uint8Array} The decoded data.
*/
function decodeBase64(base64) {
// window.atob returns a unicode string with codepoints in the range 0-255.
var latin1String = window.atob(base64);
// Encode the string as a Uint8Array
var uint8Array = new Uint8Array(latin1String.length);
for (var i = 0; i < latin1String.length; i++) {
uint8Array[i] = latin1String.charCodeAt(i);
}
return uint8Array;
}

View file

@ -36,4 +36,4 @@ export default function(WrappedComponent) {
return <WrappedComponent {...this.props} matrixClient={this.context.matrixClient} />; return <WrappedComponent {...this.props} matrixClient={this.context.matrixClient} />;
}, },
}); });
}; }

5
test/.eslintrc.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = {
env: {
mocha: true,
},
}

View file

@ -158,4 +158,85 @@ describe('MessageComposerInput', () => {
expect(['__', '**']).toContain(spy.args[0][1]); expect(['__', '**']).toContain(spy.args[0][1]);
}); });
it('should not entity-encode " in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false);
addTextToDraft('"');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('"');
});
it('should escape characters without other markup in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false);
addTextToDraft('\\*escaped\\*');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('*escaped*');
});
it('should escape characters with other markup in Markdown mode', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(false);
addTextToDraft('\\*escaped\\* *italic*');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('\\*escaped\\* *italic*');
expect(spy.args[0][2]).toEqual('*escaped* <em>italic</em>');
});
it('should not convert -_- into a horizontal rule in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false);
addTextToDraft('-_-');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('-_-');
});
it('should not strip <del> tags in Markdown mode', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(false);
addTextToDraft('<del>striked-out</del>');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('<del>striked-out</del>');
expect(spy.args[0][2]).toEqual('<del>striked-out</del>');
});
it('should not strike-through ~~~ in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false);
addTextToDraft('~~~striked-out~~~');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('~~~striked-out~~~');
});
it('should not mark single unmarkedup paragraphs as HTML in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false);
addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.');
});
it('should not mark two unmarkedup paragraphs as HTML in Markdown mode', () => {
const spy = sinon.spy(client, 'sendTextMessage');
mci.enableRichtext(false);
addTextToDraft('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.');
mci.handleReturn(sinon.stub());
expect(spy.calledOnce).toEqual(true);
expect(spy.args[0][1]).toEqual('Lorem ipsum dolor sit amet, consectetur adipiscing elit.\n\nFusce congue sapien sed neque molestie volutpat.');
});
}); });

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