merge in develop

pull/21833/head
Matthew Hodgson 2016-08-04 13:39:47 +01:00
commit 90e5ab2ca3
90 changed files with 3640 additions and 1145 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

109
.eslintrc Normal file
View File

@ -0,0 +1,109 @@
{
"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"],
"react/jsx-key": ["error"],
"react/prefer-stateless-function": ["warn"],
"react/sort-comp": ["warn"],
/** flowtype **/
"flowtype/require-parameter-type": 1,
"flowtype/require-return-type": [
1,
"always",
{
"annotateUndefined": "never"
}
],
"flowtype/space-after-type-colon": [
1,
"always"
],
"flowtype/space-before-type-colon": [
1,
"never"
]
},
"settings": {
"flowtype": {
"onlyFilesWithFlowAnnotation": true
}
}
}

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
npm-debug.log
/node_modules /node_modules
/lib /lib

180
README.md
View File

@ -3,65 +3,85 @@ matrix-react-sdk
This is a react-based SDK for inserting a Matrix chat/voip client into a web page. This is a react-based SDK for inserting a Matrix chat/voip client into a web page.
This package provides the logic and 'controller' parts for the UI components. This This package provides the React components needed to build a Matrix web client
forms one part of a complete matrix client, but it not useable in isolation. It using React. It is not useable in isolation, and instead must must be used from
must be used from a 'skin'. A skin provides: a 'skin'. A skin provides:
* The HTML for the UI components (in the form of React `render` methods) * Customised implementations of presentation components.
* The CSS for this HTML * Custom CSS
* The containing application * The containing application
* Zero or more 'modules' containing non-UI functionality * Zero or more 'modules' containing non-UI functionality
Skins are modules are exported from such a package in the `lib` directory. **WARNING: As of July 2016, the skinning abstraction is broken due to rapid
`lib/skins` contains one directory per-skin, named after the skin, and the development of `matrix-react-sdk` to meet the needs of Vector, the first app
`modules` directory contains modules as their javascript files. to be built on top of the SDK** (https://github.com/vector-im/vector-web).
Right now `matrix-react-sdk` depends on some functionality from `vector-web`
(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour
(grep for 'vector'). This layering will be fixed asap once Vector development
has stabilised, but for now we do not advise trying to create new skins for
matrix-react-sdk until the layers are clearly separated again.
A basic skin is provided in the matrix-react-skin package. This also contains In the interim, `vector-im/vector-web` and `matrix-org/matrix-react-sdk` should
a minimal application that instantiates the basic skin making a working matrix be considered as a single project (for instance, matrix-react-sdk bugs
client. are currently filed against vector-im/vector-web rather than this project).
You can use matrix-react-sdk directly, but to do this you would have to provide Developer Guide
'views' for each UI component. To get started quickly, use matrix-react-skin. ===============
How to customise the SDK Platform Targets:
======================== * Chrome, Firefox and Safari.
* Edge should also work, but we're not testing it proactively.
* WebRTC features (VoIP and Video calling) are only available in Chrome & Firefox.
* Mobile Web is not currently a target platform - instead please use the native
iOS (https://github.com/matrix-org/matrix-ios-kit) and Android
(https://github.com/matrix-org/matrix-android-sdk) SDKs.
The SDK formerly used the 'atomic' design pattern as seen at http://patternlab.io to All code lands on the `develop` branch - `master` is only used for stable releases.
encourage a very modular and reusable architecture, making it easy to **Please file PRs against `develop`!!**
customise and use UI widgets independently of the rest of the SDK and your app.
So unfortunately at the moment this document does not describe how to customize your UI! Please follow the standard Matrix contributor's guide:
https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst
###This is the old description for the atomic design pattern: Please follow the Matrix JS/React code style as per:
https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst
In practice this means: Whilst the layering separation between matrix-react-sdk and Vector is broken
(as of July 2016), code should be committed as follows:
* All new components: https://github.com/matrix-org/matrix-react-sdk/tree/master/src/components
* Vector-specific components: https://github.com/vector-im/vector-web/tree/master/src/components
* In practice, `matrix-react-sdk` is still evolving so fast that the maintenance
burden of customising and overriding these components for Vector can seriously
impede development. So right now, there should be very few (if any) customisations for Vector.
* CSS for Matrix SDK components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk
* CSS for Vector-specific overrides and components: https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/vector-web
* The UI of the app is strictly split up into a hierarchy of components. React components in matrix-react-sdk are come in two different flavours:
'structures' and 'views'. Structures are stateful components which handle the
* Each component has its own: more complicated business logic of the app, delegating their actual presentation
* View object defined as a React javascript class containing embedded rendering to stateless 'view' components. For instance, the RoomView component
HTML expressed in React's JSX notation. that orchestrates the act of visualising the contents of a given Matrix chat room
* CSS file, which defines the styling specific to that component. tracks lots of state for its child components which it passes into them for visual
rendering via props.
* Components are loosely grouped into the 5 levels outlined by atomic design:
* atoms: fundamental building blocks (e.g. a timestamp tag)
* molecules: "group of atoms which functions together as a unit"
(e.g. a message in a chat timeline)
* organisms: "groups of molecules (and atoms) which form a distinct section
of a UI" (e.g. a view of a chat room)
* templates: "a reusable configuration of organisms" - used to combine and
style organisms into a well-defined global look and feel
* pages: specific instances of templates.
Good separation between the components is maintained by adopting various best Good separation between the components is maintained by adopting various best
practices that anyone working with the SDK needs to be be aware of and uphold: practices that anyone working with the SDK needs to be be aware of and uphold:
* Views are named with upper camel case (e.g. molecules/MessageTile.js) * Components are named with upper camel case (e.g. views/rooms/EventTile.js)
* The view's CSS file MUST have the same name (e.g. molecules/MessageTile.css) * They are organised in a typically two-level hierarchy - first whether the
component is a view or a structure, and then a broad functional grouping
(e.g. 'rooms' here)
* After creating a new component you must run `npm run reskindex` to regenerate
the `component-index.js` for the SDK (used in future for skinning)
* The view's CSS file MUST have the same name (e.g. view/rooms/MessageTile.css).
CSS for matrix-react-sdk currently resides in
https://github.com/vector-im/vector-web/tree/master/src/skins/vector/css/matrix-react-sdk.
* Per-view CSS is optional - it could choose to inherit all its styling from * Per-view CSS is optional - it could choose to inherit all its styling from
the context of the rest of the app, although this is unusual for any but the context of the rest of the app, although this is unusual for any but
the simplest atoms and molecules. structural components (lacking presentation logic) and the simplest view
components.
* The view MUST *only* refer to the CSS rules defined in its own CSS file. * The view MUST *only* refer to the CSS rules defined in its own CSS file.
'Stealing' styling information from other components (including parents) 'Stealing' styling information from other components (including parents)
@ -82,9 +102,10 @@ In practice this means:
* We deliberately use vanilla CSS 3.0 to avoid adding any more magic * We deliberately use vanilla CSS 3.0 to avoid adding any more magic
dependencies into the mix than we already have. App developers are welcome dependencies into the mix than we already have. App developers are welcome
to use whatever floats their boat however. to use whatever floats their boat however. In future we'll start using
css-next to pull in features like CSS variable support.
* The CSS for a component can however override the rules for child components. * The CSS for a component can override the rules for child components.
For instance, .mx_RoomList .mx_RoomTile {} would be the selector to override For instance, .mx_RoomList .mx_RoomTile {} would be the selector to override
styles of RoomTiles when viewed in the context of a RoomList view. styles of RoomTiles when viewed in the context of a RoomList view.
Overrides *must* be scoped to the View's CSS class - i.e. don't just define Overrides *must* be scoped to the View's CSS class - i.e. don't just define
@ -98,30 +119,36 @@ In practice this means:
generally not cool and stop the component from being reused easily in generally not cool and stop the component from being reused easily in
different places. different places.
* We don't use the atomify library itself, as React already provides most Originally `matrix-react-sdk` followed the Atomic design pattern as per
of the modularity requirements it brings to the table. http://patternlab.io to try to encourage a modular architecture. However, we
found that the grouping of components into atoms/molecules/organisms
made them harder to find relative to a functional split, and didn't emphasise
the distinction between 'structural' and 'view' components, so we backed away
from it.
With all this in mind, here's how you go about skinning the react SDK UI Github Issues
components to embed a Matrix client into your app: =============
* Create a new NPM project. Be sure to directly depend on react, (otherwise All issues should be filed under https://github.com/vector-im/vector-web/issues
you can end up with two copies of react). for now.
* Create an index.js file that sets up react. Add require statements for
React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the OUTDATED: To Create Your Own Skin
SDK and call Render. This can be a skin provided by a separate package or =================================
a skin in the same package.
* Add a way to build your project: we suggest copying the scripts block **This is ALL LIES currently, as skinning is currently broken - see the WARNING
from matrix-react-skin (which uses babel and webpack). You could use section at the top of this readme.**
different tools but remember that at least the skins and modules of
your project should end up in plain (ie. non ES6, non JSX) javascript in Skins are modules are exported from such a package in the `lib` directory.
the lib directory at the end of the build process, as well as any `lib/skins` contains one directory per-skin, named after the skin, and the
packaging that you might do. `modules` directory contains modules as their javascript files.
* Create an index.html file pulling in your compiled javascript and the
CSS bundle from the skin you use. For now, you'll also need to manually A basic skin is provided in the matrix-react-skin package. This also contains
import CSS from any skins that your skin inherts from. a minimal application that instantiates the basic skin making a working matrix
client.
You can use matrix-react-sdk directly, but to do this you would have to provide
'views' for each UI component. To get started quickly, use matrix-react-skin.
To Create Your Own Skin
=======================
To actually change the look of a skin, you can create a base skin (which To actually change the look of a skin, you can create a base skin (which
does not use views from any other skin) or you can make a derived skin. does not use views from any other skin) or you can make a derived skin.
Note that derived skins are currently experimental: for example, the CSS Note that derived skins are currently experimental: for example, the CSS
@ -145,3 +172,22 @@ Now you have the basis of a skin, you need to generate a skindex.json file. The
you add an npm script to run this, as in matrix-react-skin. you add an npm script to run this, as in matrix-react-skin.
For more specific detail on any of these steps, look at matrix-react-skin. For more specific detail on any of these steps, look at matrix-react-skin.
Alternative instructions:
* Create a new NPM project. Be sure to directly depend on react, (otherwise
you can end up with two copies of react).
* Create an index.js file that sets up react. Add require statements for
React and matrix-react-sdk. Load a skin using the 'loadSkin' method on the
SDK and call Render. This can be a skin provided by a separate package or
a skin in the same package.
* Add a way to build your project: we suggest copying the scripts block
from matrix-react-skin (which uses babel and webpack). You could use
different tools but remember that at least the skins and modules of
your project should end up in plain (ie. non ES6, non JSX) javascript in
the lib directory at the end of the build process, as well as any
packaging that you might do.
* Create an index.html file pulling in your compiled javascript and the
CSS bundle from the skin you use. For now, you'll also need to manually
import CSS from any skins that your skin inherts from.

162
code_style.md Normal file
View File

@ -0,0 +1,162 @@
Matrix JavaScript/ECMAScript Style Guide
========================================
The intention of this guide is to make Matrix's JavaScript codebase clean,
consistent with other popular JavaScript styles and consistent with the rest of
the Matrix codebase. For reference, the Matrix Python style guide can be found
at https://github.com/matrix-org/synapse/blob/master/docs/code_style.rst
This document reflects how we would like Matrix JavaScript code to look, with
acknowledgement that a significant amount of code is written to older
standards.
Write applications in modern ECMAScript and use a transpiler where necessary to
target older platforms. When writing library code, consider carefully whether
to write in ES5 to allow all JavaScript application to use the code directly or
writing in modern ECMAScript and using a transpile step to generate the file
that applications can then include. There are significant benefits in being
able to use modern ECMAScript, although the tooling for doing so can be awkward
for library code, especially with regard to translating source maps and line
number throgh from the original code to the final application.
General Style
-------------
- 4 spaces to indent, for consistency with Matrix Python.
- 120 columns per line, but try to keep JavaScript code around the 80 column mark.
Inline JSX in particular can be nicer with more columns per line.
- No trailing whitespace at end of lines.
- Don't indent empty lines.
- One newline at the end of the file.
- Unix newlines, never `\r`
- Indent similar to our python code: break up long lines at logical boundaries,
more than one argument on a line is OK
- Use semicolons, for consistency with node.
- UpperCamelCase for class and type names
- lowerCamelCase for functions and variables.
- Single line ternary operators are fine.
- UPPER_CAMEL_CASE for constants
- Single quotes for strings by default, for consistency with most JavaScript styles:
```javascript
"bad" // Bad
'good' // Good
```
- Use parentheses or `\`` instead of '\\' for line continuation where ever possible
- Open braces on the same line (consistent with Node):
```javascript
if (x) {
console.log("I am a fish"); // Good
}
if (x)
{
console.log("I am a fish"); // Bad
}
```
- Spaces after `if`, `for`, `else` etc, no space around the condition:
```javascript
if (x) {
console.log("I am a fish"); // Good
}
if(x) {
console.log("I am a fish"); // Bad
}
if ( x ) {
console.log("I am a fish"); // Bad
}
```
- Declare one variable per var statement (consistent with Node). Unless they
are simple and closely related. If you put the next declaration on a new line,
treat yourself to another `var`:
```javascript
var key = "foo",
comparator = function(x, y) {
return x - y;
}; // Bad
var key = "foo";
var comparator = function(x, y) {
return x - y;
}; // Good
var x = 0, y = 0; // Fine
var x = 0;
var y = 0; // Also fine
```
- A single line `if` is fine, all others have braces. This prevents errors when adding to the code.:
```javascript
if (x) return true; // Fine
if (x) {
return true; // Also fine
}
if (x)
return true; // Not fine
```
- Terminate all multi-line lists, object literals, imports and ideally function calls with commas (if using a transpiler). Note that trailing function commas require explicit configuration in babel at time of writing:
```javascript
var mascots = [
"Patrick",
"Shirley",
"Colin",
"Susan",
"Sir Arthur David" // Bad
];
var mascots = [
"Patrick",
"Shirley",
"Colin",
"Susan",
"Sir Arthur David", // Good
];
```
- Use `null`, `undefined` etc consistently with node:
Boolean variables and functions should always be either true or false. Don't set it to 0 unless it's supposed to be a number.
When something is intentionally missing or removed, set it to null.
If returning a boolean, type coerce:
```javascript
function hasThings() {
return !!length; // bad
return new Boolean(length); // REALLY bad
return Boolean(length); // good
}
```
Don't set things to undefined. Reserve that value to mean "not yet set to anything."
Boolean objects are verboten.
- Use JSDoc
ECMAScript
----------
- Use `const` unless you need a re-assignable variable. This ensures things you don't want to be re-assigned can't be.
- Be careful migrating files to newer syntax.
- Don't mix `require` and `import` in the same file. Either stick to the old style or change them all.
- Likewise, don't mix things like class properties and `MyClass.prototype.MY_CONSTANT = 42;`
- Be careful mixing arrow functions and regular functions, eg. if one function in a promise chain is an
arrow function, they probably all should be.
- Apart from that, newer ES features should be used whenever the author deems them to be appropriate.
- Flow annotations are welcome and encouraged.
React
-----
- Use ES6 classes, although bear in mind a lot of code uses createClass.
- Pull out functions in props to the class, generally as specific event handlers:
```jsx
<Foo onClick={function(ev) {doStuff();}}> // Bad
<Foo onClick={(ev) => {doStuff();}}> // Equally bad
<Foo onClick={this.doStuff}> // Better
<Foo onClick={this.onFooClick}> // Best, if onFooClick would do anything other than directly calling doStuff
```
- Think about whether your component really needs state: are you duplicating
information in component state that could be derived from the model?

View File

@ -2,6 +2,7 @@
set -e set -e
export KARMAFLAGS="--no-colors"
export NVM_DIR="/home/jenkins/.nvm" export NVM_DIR="/home/jenkins/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm use 4 nvm use 4
@ -14,6 +15,9 @@ npm install
# run the mocha tests # run the mocha tests
npm run test npm run test
# run eslint
npm run lint -- -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

@ -171,5 +171,6 @@ module.exports = function (config) {
}, },
devtool: 'inline-source-map', devtool: 'inline-source-map',
}, },
browserNoActivityTimeout: 15000,
}); });
}; };

View File

@ -13,13 +13,15 @@
"reskindex": "./reskindex.js" "reskindex": "./reskindex.js"
}, },
"scripts": { "scripts": {
"reskindex": "reskindex -h header", "reskindex": "./reskindex.js -h header",
"build": "babel src -d lib --source-maps", "build": "babel src -d lib --source-maps --stage 1",
"start": "babel src -w -d lib --source-maps", "start": "babel src -w -d lib --source-maps --stage 1",
"lint": "eslint src/",
"lintall": "eslint src/ test/",
"clean": "rimraf lib", "clean": "rimraf lib",
"prepublish": "npm run build && git rev-parse HEAD > git-revision.txt", "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt",
"test": "karma start --browsers PhantomJS", "test": "karma start $KARMAFLAGS --browsers PhantomJS",
"test-multi": "karma start --single-run=false" "test-multi": "karma start $KARMAFLAGS --single-run=false"
}, },
"dependencies": { "dependencies": {
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
@ -28,21 +30,26 @@
"draft-js-export-html": "^0.2.2", "draft-js-export-html": "^0.2.2",
"draft-js-export-markdown": "^0.2.0", "draft-js-export-markdown": "^0.2.0",
"draft-js-import-markdown": "^0.1.6", "draft-js-import-markdown": "^0.1.6",
"emojione": "^2.2.2",
"favico.js": "^0.3.10", "favico.js": "^0.3.10",
"filesize": "^3.1.2", "filesize": "^3.1.2",
"flux": "^2.0.3", "flux": "^2.0.3",
"fuse.js": "^2.2.0",
"glob": "^5.0.14", "glob": "^5.0.14",
"highlight.js": "^8.9.1", "highlight.js": "^8.9.1",
"linkifyjs": "^2.0.0-beta.4", "linkifyjs": "^2.0.0-beta.4",
"lodash": "^4.13.1",
"marked": "^0.3.5", "marked": "^0.3.5",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"optimist": "^0.6.1", "optimist": "^0.6.1",
"q": "^1.4.1", "q": "^1.4.1",
"react": "^15.0.1", "react": "^15.2.1",
"react-dom": "^15.0.1", "react-addons-css-transition-group": "^15.2.1",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "react-dom": "^15.2.1",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",
"velocity-vector": "vector-im/velocity#059e3b2" "velocity-vector": "vector-im/velocity#059e3b2",
"whatwg-fetch": "^1.0.0"
}, },
"//babelversion": [ "//babelversion": [
"brief experiments with babel6 seems to show that it generates source ", "brief experiments with babel6 seems to show that it generates source ",
@ -52,8 +59,12 @@
"devDependencies": { "devDependencies": {
"babel": "^5.8.23", "babel": "^5.8.23",
"babel-core": "^5.8.38", "babel-core": "^5.8.38",
"babel-eslint": "^6.1.0",
"babel-loader": "^5.4.0", "babel-loader": "^5.4.0",
"babel-polyfill": "^6.5.0", "babel-polyfill": "^6.5.0",
"eslint": "^2.13.1",
"eslint-plugin-flowtype": "^2.3.0",
"eslint-plugin-react": "^5.2.2",
"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

@ -38,11 +38,13 @@ class AddThreepid {
*/ */
addEmailAddress(emailAddress, bind) { addEmailAddress(emailAddress, bind) {
this.bind = bind; this.bind = bind;
return MatrixClientPeg.get().requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.httpStatus) { if (err.errcode == 'M_THREEPID_IN_USE') {
err.message = "This email address is already in use";
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
} }
throw err; throw err;

View File

@ -181,11 +181,11 @@ function _onAction(payload) {
console.error("Unknown conf call type: %s", payload.type); console.error("Unknown conf call type: %s", payload.type);
} }
} }
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
switch (payload.action) { switch (payload.action) {
case 'place_call': case 'place_call':
if (module.exports.getAnyActiveCall()) { if (module.exports.getAnyActiveCall()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Existing Call", title: "Existing Call",
description: "You are already in a call." description: "You are already in a call."
@ -195,6 +195,7 @@ function _onAction(payload) {
// if the runtime env doesn't do VoIP, whine. // if the runtime env doesn't do VoIP, whine.
if (!MatrixClientPeg.get().supportsVoip()) { if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "VoIP is unsupported", title: "VoIP is unsupported",
description: "You cannot place VoIP calls in this browser." description: "You cannot place VoIP calls in this browser."
@ -210,7 +211,7 @@ function _onAction(payload) {
var members = room.getJoinedMembers(); var members = room.getJoinedMembers();
if (members.length <= 1) { if (members.length <= 1) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
description: "You cannot place a call with yourself." description: "You cannot place a call with yourself."
}); });
@ -236,23 +237,37 @@ function _onAction(payload) {
case 'place_conference_call': case 'place_conference_call':
console.log("Place conference call in %s", payload.room_id); console.log("Place conference call in %s", payload.room_id);
if (!ConferenceHandler) { if (!ConferenceHandler) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
description: "Conference calls are not supported in this client" description: "Conference calls are not supported in this client"
}); });
} }
else if (!MatrixClientPeg.get().supportsVoip()) { else if (!MatrixClientPeg.get().supportsVoip()) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "VoIP is unsupported", title: "VoIP is unsupported",
description: "You cannot place VoIP calls in this browser." description: "You cannot place VoIP calls in this browser."
}); });
} }
else { else {
ConferenceHandler.createNewMatrixCall( var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
MatrixClientPeg.get(), payload.room_id Modal.createDialog(QuestionDialog, {
).done(function(call) { title: "Warning!",
placeCall(call); description: "Conference calling in Vector is in development and may not be reliable.",
}, function(err) { onFinished: confirm=>{
console.error("Failed to setup conference call: %s", err); if (confirm) {
ConferenceHandler.createNewMatrixCall(
MatrixClientPeg.get(), payload.room_id
).done(function(call) {
placeCall(call);
}, function(err) {
Modal.createDialog(ErrorDialog, {
title: "Failed to set up conference call",
description: "Conference call failed: " + err,
});
});
}
},
}); });
} }
break; break;

View File

@ -52,6 +52,36 @@ function infoForImageFile(imageFile) {
return deferred.promise; return deferred.promise;
} }
function infoForVideoFile(videoFile) {
var deferred = q.defer();
// Load the file into an html element
var video = document.createElement("video");
var reader = new FileReader();
reader.onload = function(e) {
video.src = e.target.result;
// Once ready, returns its size
video.onloadedmetadata = function() {
deferred.resolve({
w: video.videoWidth,
h: video.videoHeight
});
};
video.onerror = function(e) {
deferred.reject(e);
};
};
reader.onerror = function(e) {
deferred.reject(e);
};
reader.readAsDataURL(videoFile);
return deferred.promise;
}
class ContentMessages { class ContentMessages {
constructor() { constructor() {
this.inprogress = []; this.inprogress = [];
@ -74,13 +104,25 @@ class ContentMessages {
var def = q.defer(); var def = q.defer();
if (file.type.indexOf('image/') == 0) { if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image'; content.msgtype = 'm.image';
infoForImageFile(file).then(function (imageInfo) { infoForImageFile(file).then(imageInfo=>{
extend(content.info, imageInfo); extend(content.info, imageInfo);
def.resolve(); def.resolve();
}, error=>{
content.msgtype = 'm.file';
def.resolve();
}); });
} else if (file.type.indexOf('audio/') == 0) { } else if (file.type.indexOf('audio/') == 0) {
content.msgtype = 'm.audio'; content.msgtype = 'm.audio';
def.resolve(); def.resolve();
} else if (file.type.indexOf('video/') == 0) {
content.msgtype = 'm.video';
infoForVideoFile(file).then(videoInfo=>{
extend(content.info, videoInfo);
def.resolve();
}, error=>{
content.msgtype = 'm.file';
def.resolve();
});
} else { } else {
content.msgtype = 'm.file'; content.msgtype = 'm.file';
def.resolve(); def.resolve();

View File

@ -1,51 +0,0 @@
/*
Copyright 2015 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const IS_GUEST_KEY = "matrix-is-guest";
class GuestAccess {
constructor(localStorage) {
this.localStorage = localStorage;
try {
this._isGuest = localStorage.getItem(IS_GUEST_KEY) === "true";
}
catch (e) {} // don't care
}
setPeekedRoom(roomId) {
// we purposefully do not persist this to local storage as peeking is
// entirely transient.
this._peekedRoomId = roomId;
}
getPeekedRoom() {
return this._peekedRoomId;
}
isGuest() {
return this._isGuest;
}
markAsGuest(isGuest) {
try {
this.localStorage.setItem(IS_GUEST_KEY, JSON.stringify(isGuest));
} catch (e) {} // ignore. If they don't do LS, they'll just get a new account.
this._isGuest = isGuest;
this._peekedRoomId = null;
}
}
module.exports = GuestAccess;

View File

@ -20,6 +20,11 @@ var React = require('react');
var sanitizeHtml = require('sanitize-html'); var sanitizeHtml = require('sanitize-html');
var highlight = require('highlight.js'); var highlight = require('highlight.js');
var linkifyMatrix = require('./linkify-matrix'); var linkifyMatrix = require('./linkify-matrix');
import escape from 'lodash/escape';
import emojione from 'emojione';
import classNames from 'classnames';
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi");
var sanitizeHtmlParams = { var sanitizeHtmlParams = {
allowedTags: [ allowedTags: [
@ -28,7 +33,7 @@ var sanitizeHtmlParams = {
// deliberately no h1/h2 to stop people shouting. // deliberately no h1/h2 to stop people shouting.
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div',
'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre' 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'img',
], ],
allowedAttributes: { allowedAttributes: {
// custom ones first: // custom ones first:
@ -42,7 +47,9 @@ var sanitizeHtmlParams = {
selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ], selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ],
// URL schemes we permit // URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ],
allowedSchemesByTag: {}, allowedSchemesByTag: {
img: [ 'data' ],
},
transformTags: { // custom to matrix transformTags: { // custom to matrix
// add blank targets to all hyperlinks except vector URLs // add blank targets to all hyperlinks except vector URLs
@ -179,46 +186,49 @@ module.exports = {
* *
* highlights: optional list of words to highlight, ordered by longest word first * highlights: optional list of words to highlight, ordered by longest word first
* *
* opts.highlightLink: optional href to add to highlights * opts.highlightLink: optional href to add to highlighted words
*/ */
bodyToHtml: function(content, highlights, opts) { bodyToHtml: function(content, highlights, opts) {
opts = opts || {}; opts = opts || {};
var isHtml = (content.format === "org.matrix.custom.html"); var isHtml = (content.format === "org.matrix.custom.html");
let body = isHtml ? content.formatted_body : escape(content.body);
var safeBody; var safeBody;
if (isHtml) { // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which // are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted // by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either
// by an attempt to search for 'foobar'. Then again, the search query probably wouldn't work either try {
try {
if (highlights && highlights.length > 0) {
var highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
var safeHighlights = highlights.map(function(highlight) {
return sanitizeHtml(highlight, sanitizeHtmlParams);
});
// XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure.
sanitizeHtmlParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join('');
};
}
safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams);
}
finally {
delete sanitizeHtmlParams.textFilter;
}
return <span className="markdown-body" dangerouslySetInnerHTML={{ __html: safeBody }} />;
} else {
safeBody = content.body;
if (highlights && highlights.length > 0) { if (highlights && highlights.length > 0) {
var highlighter = new TextHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); var highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
return highlighter.applyHighlights(safeBody, highlights); var safeHighlights = highlights.map(function(highlight) {
} return sanitizeHtml(highlight, sanitizeHtmlParams);
else { });
return safeBody; // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeHtmlParams structure.
sanitizeHtmlParams.textFilter = function(safeText) {
return highlighter.applyHighlights(safeText, safeHighlights).join('');
};
} }
safeBody = sanitizeHtml(body, sanitizeHtmlParams);
emojione.imageType = 'svg';
safeBody = emojione.unicodeToImage(safeBody);
} }
finally {
delete sanitizeHtmlParams.textFilter;
}
EMOJI_REGEX.lastIndex = 0;
let contentBodyTrimmed = content.body.trim();
let match = EMOJI_REGEX.exec(contentBodyTrimmed);
let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length;
const className = classNames({
'mx_EventTile_body': true,
'mx_EventTile_bigEmoji': emojiBody,
'markdown-body': isHtml,
});
return <span className={className} dangerouslySetInnerHTML={{ __html: safeBody }} />;
}, },
highlightDom: function(element) { highlightDom: function(element) {
@ -228,5 +238,11 @@ module.exports = {
} }
}, },
} emojifyText: function(text) {
emojione.imageType = 'svg';
return {
__html: emojione.unicodeToImage(escape(text)),
};
},
};

110
src/Lifecycle.js Normal file
View File

@ -0,0 +1,110 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import MatrixClientPeg from './MatrixClientPeg';
import Notifier from './Notifier'
import UserActivity from './UserActivity';
import Presence from './Presence';
import dis from './dispatcher';
/**
* Transitions to a logged-in state using the given credentials
* @param {MatrixClientCreds} credentials The credentials to use
*/
function setLoggedIn(credentials) {
credentials.guest = Boolean(credentials.guest);
console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest);
MatrixClientPeg.replaceUsingCreds(credentials);
dis.dispatch({action: 'on_logged_in'});
startMatrixClient();
}
/**
* Logs the current session out and transitions to the logged-out state
*/
function logout() {
if (MatrixClientPeg.get().isGuest()) {
// logout doesn't work for guest sessions
// Also we sometimes want to re-log in a guest session
// if we abort the login
_onLoggedOut();
return;
}
return MatrixClientPeg.get().logout().then(_onLoggedOut,
(err) => {
// Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and
// you want to log into a different server, so just forget the
// access token. It's annoying that this will leave the access
// token still valid, but we should fix this by having access
// tokens expire (and if you really think you've been compromised,
// change your password).
console.log("Failed to call logout API: token will not be invalidated");
_onLoggedOut();
}
);
}
/**
* Starts the matrix client and all other react-sdk services that
* listen for events while a session is logged in.
*/
function startMatrixClient() {
// dispatch this before starting the matrix client: it's used
// to add listeners for the 'sync' event so otherwise we'd have
// a race condition (and we need to dispatch synchronously for this
// to work).
dis.dispatch({action: 'will_start_client'}, true);
Notifier.start();
UserActivity.start();
Presence.start();
MatrixClientPeg.start();
}
function _onLoggedOut() {
if (window.localStorage) {
const hsUrl = window.localStorage.getItem("mx_hs_url");
const isUrl = window.localStorage.getItem("mx_is_url");
window.localStorage.clear();
// preserve our HS & IS URLs for convenience
// N.B. we cache them in hsUrl/isUrl and can't really inline them
// as getCurrentHsUrl() may call through to localStorage.
if (hsUrl) window.localStorage.setItem("mx_hs_url", hsUrl);
if (isUrl) window.localStorage.setItem("mx_is_url", isUrl);
}
_stopMatrixClient();
dis.dispatch({action: 'on_logged_out'});
}
// stop all the background processes related to the current client
function _stopMatrixClient() {
Notifier.stop();
UserActivity.stop();
Presence.stop();
MatrixClientPeg.get().stopClient();
MatrixClientPeg.get().removeAllListeners();
MatrixClientPeg.unset();
}
module.exports = {
setLoggedIn, logout, startMatrixClient
};

View File

@ -16,13 +16,10 @@ limitations under the License.
'use strict'; 'use strict';
// A thing that holds your Matrix Client import Matrix from 'matrix-js-sdk';
var Matrix = require("matrix-js-sdk"); import utils from 'matrix-js-sdk/lib/utils';
var GuestAccess = require("./GuestAccess");
var matrixClient = null; const localStorage = window.localStorage;
var localStorage = window.localStorage;
function deviceId() { function deviceId() {
// XXX: is Math.random()'s deterministicity a problem here? // XXX: is Math.random()'s deterministicity a problem here?
@ -35,97 +32,71 @@ function deviceId() {
return id; return id;
} }
function createClientForPeg(hs_url, is_url, user_id, access_token, guestAccess) { interface MatrixClientCreds {
var opts = { homeserverUrl: string,
baseUrl: hs_url, identityServerUrl: string,
idBaseUrl: is_url, userId: string,
accessToken: access_token, accessToken: string,
userId: user_id, guest: boolean,
timelineSupport: true,
};
if (localStorage) {
opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
opts.deviceId = deviceId();
}
matrixClient = Matrix.createClient(opts);
// we're going to add eventlisteners for each matrix event tile, so the
// potential number of event listeners is quite high.
matrixClient.setMaxListeners(500);
if (guestAccess) {
console.log("Guest: %s", guestAccess.isGuest());
matrixClient.setGuest(guestAccess.isGuest());
var peekedRoomId = guestAccess.getPeekedRoom();
if (peekedRoomId) {
console.log("Peeking in room %s", peekedRoomId);
matrixClient.peekInRoom(peekedRoomId);
}
}
} }
if (localStorage) { /**
var hs_url = localStorage.getItem("mx_hs_url"); * Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org'; * Handles the creation/initialisation of client objects.
var access_token = localStorage.getItem("mx_access_token"); * This module provides a singleton instance of this class so the 'current'
var user_id = localStorage.getItem("mx_user_id"); * Matrix Client object is available easily.
var guestAccess = new GuestAccess(localStorage); */
if (access_token && user_id && hs_url) { class MatrixClientPeg {
console.log("Restoring session for %s", user_id); constructor() {
createClientForPeg(hs_url, is_url, user_id, access_token, guestAccess); this.matrixClient = null;
}
else {
console.log("Session not found.");
}
}
class MatrixClient { // These are the default options used when when the
// client is started in 'start'. These can be altered
constructor(guestAccess) { // at any time up to after the 'will_start_client'
this.guestAccess = guestAccess; // event is finished processing.
this.opts = {
initialSyncLimit: 20,
};
} }
get() { get(): MatrixClient {
return matrixClient; return this.matrixClient;
} }
unset() { unset() {
matrixClient = null; this.matrixClient = null;
} }
// FIXME, XXX: this all seems very convoluted :( /**
// * Replace this MatrixClientPeg's client with a client instance that has
// if we replace the singleton using URLs we bypass our createClientForPeg() * Home Server / Identity Server URLs but no credentials
// global helper function... but if we replace it using */
// an access_token we don't?
//
// Why do we have this peg wrapper rather than just MatrixClient.get()?
// Why do we name MatrixClient as MatrixClientPeg when we export it?
//
// -matthew
replaceUsingUrls(hs_url, is_url) { replaceUsingUrls(hs_url, is_url) {
matrixClient = Matrix.createClient({ this._replaceClient(hs_url, is_url);
baseUrl: hs_url,
idBaseUrl: is_url
});
// XXX: factor this out with the localStorage setting in replaceUsingAccessToken
if (localStorage) {
try {
localStorage.setItem("mx_hs_url", hs_url);
localStorage.setItem("mx_is_url", is_url);
} catch (e) {
console.warn("Error using local storage: can't persist HS/IS URLs!");
}
} else {
console.warn("No local storage available: can't persist HS/IS URLs!");
}
} }
replaceUsingAccessToken(hs_url, is_url, user_id, access_token, isGuest) { /**
* Replace this MatrixClientPeg's client with a client instance that has
* Home Server / Identity Server URLs and active credentials
*/
replaceUsingCreds(creds: MatrixClientCreds) {
this._replaceClient(
creds.homeserverUrl,
creds.identityServerUrl,
creds.userId,
creds.accessToken,
creds.guest,
);
}
start() {
const opts = utils.deepCopy(this.opts);
// the react sdk doesn't work without this, so don't allow
opts.pendingEventOrdering = "detached";
this.get().startClient(opts);
}
_replaceClient(hs_url, is_url, user_id, access_token, isGuest) {
if (localStorage) { if (localStorage) {
try { try {
localStorage.clear(); localStorage.clear();
@ -133,15 +104,19 @@ class MatrixClient {
console.warn("Error clearing local storage", e); console.warn("Error clearing local storage", e);
} }
} }
this.guestAccess.markAsGuest(Boolean(isGuest)); this._createClient(hs_url, is_url, user_id, access_token, isGuest);
createClientForPeg(hs_url, is_url, user_id, access_token, this.guestAccess);
if (localStorage) { if (localStorage) {
try { try {
localStorage.setItem("mx_hs_url", hs_url); localStorage.setItem("mx_hs_url", hs_url);
localStorage.setItem("mx_is_url", is_url); localStorage.setItem("mx_is_url", is_url);
localStorage.setItem("mx_user_id", user_id);
localStorage.setItem("mx_access_token", access_token); if (user_id !== undefined && access_token !== undefined) {
console.log("Session persisted for %s", user_id); localStorage.setItem("mx_user_id", user_id);
localStorage.setItem("mx_access_token", access_token);
localStorage.setItem("mx_is_guest", JSON.stringify(isGuest));
console.log("Session persisted for %s", user_id);
}
} catch (e) { } catch (e) {
console.warn("Error using local storage: can't persist session!", e); console.warn("Error using local storage: can't persist session!", e);
} }
@ -149,9 +124,68 @@ class MatrixClient {
console.warn("No local storage available: can't persist session!"); console.warn("No local storage available: can't persist session!");
} }
} }
getCredentials(): MatrixClientCreds {
return {
homeserverUrl: this.matrixClient.baseUrl,
identityServerUrl: this.matrixClient.idBaseUrl,
userId: this.matrixClient.credentials.userId,
accessToken: this.matrixClient.getAccessToken(),
guest: this.matrixClient.isGuest(),
};
}
tryRestore() {
if (localStorage) {
const hs_url = localStorage.getItem("mx_hs_url");
const is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org';
const access_token = localStorage.getItem("mx_access_token");
const user_id = localStorage.getItem("mx_user_id");
let is_guest;
if (localStorage.getItem("mx_is_guest") !== null) {
is_guest = localStorage.getItem("mx_is_guest") === "true";
} else {
// legacy key name
is_guest = localStorage.getItem("matrix-is-guest") === "true";
}
if (access_token && user_id && hs_url) {
console.log("Restoring session for %s", user_id);
this._createClient(hs_url, is_url, user_id, access_token);
this.matrixClient.setGuest(is_guest);
} else {
console.log("Session not found.");
}
}
}
_createClient(hs_url, is_url, user_id, access_token, isGuest) {
var opts = {
baseUrl: hs_url,
idBaseUrl: is_url,
accessToken: access_token,
userId: user_id,
timelineSupport: true,
};
if (localStorage) {
opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage);
opts.deviceId = deviceId();
}
this.matrixClient = Matrix.createClient(opts);
// we're going to add eventlisteners for each matrix event tile, so the
// potential number of event listeners is quite high.
this.matrixClient.setMaxListeners(500);
this.matrixClient.setGuest(Boolean(isGuest));
}
} }
if (!global.mxMatrixClient) { if (!global.mxMatrixClientPeg) {
global.mxMatrixClient = new MatrixClient(new GuestAccess(localStorage)); global.mxMatrixClientPeg = new MatrixClientPeg();
global.mxMatrixClientPeg.tryRestore();
} }
module.exports = global.mxMatrixClient; module.exports = global.mxMatrixClientPeg;

View File

@ -24,30 +24,5 @@ module.exports = {
getDisplayAliasForRoom: function(room) { getDisplayAliasForRoom: function(room) {
return room.getCanonicalAlias() || room.getAliases()[0]; return room.getCanonicalAlias() || room.getAliases()[0];
}, },
/**
* Given a list of room objects, return the room which has the given alias,
* else null.
*/
getRoomForAlias: function(rooms, room_alias) {
var room;
for (var i = 0; i < rooms.length; i++) {
var aliasEvents = rooms[i].currentState.getStateEvents(
"m.room.aliases"
);
for (var j = 0; j < aliasEvents.length; j++) {
var aliases = aliasEvents[j].getContent().aliases || [];
for (var k = 0; k < aliases.length; k++) {
if (aliases[k] === room_alias) {
room = rooms[i];
break;
}
}
if (room) { break; }
}
if (room) { break; }
}
return room || null;
}
} }

View File

@ -48,11 +48,13 @@ class PasswordReset {
*/ */
resetPassword(emailAddress, newPassword) { resetPassword(emailAddress, newPassword) {
this.password = newPassword; this.password = newPassword;
return this.client.requestEmailToken(emailAddress, this.clientSecret, 1).then((res) => { return this.client.requestPasswordEmailToken(emailAddress, this.clientSecret, 1).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
}, function(err) { }, function(err) {
if (err.httpStatus) { if (err.errcode == 'M_THREEPID_NOT_FOUND') {
err.message = "This email address was not found";
} else if (err.httpStatus) {
err.message = err.message + ` (Status ${err.httpStatus})`; err.message = err.message + ` (Status ${err.httpStatus})`;
} }
throw err; throw err;

View File

@ -1,16 +1,22 @@
import React from 'react';
import { import {
Editor, Editor,
EditorState,
Modifier, Modifier,
ContentState, ContentState,
ContentBlock,
convertFromHTML, convertFromHTML,
DefaultDraftBlockRenderMap, DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle, DefaultDraftInlineStyle,
CompositeDecorator CompositeDecorator,
SelectionState,
Entity,
} from 'draft-js'; } from 'draft-js';
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione';
const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
element: 'span' element: 'span',
/* /*
draft uses <div> by default which we don't really like, so we're using <span> draft uses <div> by default which we don't really like, so we're using <span>
this is probably not a good idea since <span> is not a block level element but this is probably not a good idea since <span> is not a block level element but
@ -23,17 +29,18 @@ const STYLES = {
CODE: 'code', CODE: 'code',
ITALIC: 'em', ITALIC: 'em',
STRIKETHROUGH: 's', STRIKETHROUGH: 's',
UNDERLINE: 'u' UNDERLINE: 'u',
}; };
const MARKDOWN_REGEX = { const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g, LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g, ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
}; };
const USERNAME_REGEX = /@\S+:\S+/g; const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export function contentStateToHTML(contentState: ContentState): string { export function contentStateToHTML(contentState: ContentState): string {
return contentState.getBlockMap().map((block) => { return contentState.getBlockMap().map((block) => {
@ -60,7 +67,7 @@ export function contentStateToHTML(contentState: ContentState): string {
let result = `<${elem}>${content.join('')}</${elem}>`; let result = `<${elem}>${content.join('')}</${elem}>`;
// dirty hack because we don't want block level tags by default, but breaks // dirty hack because we don't want block level tags by default, but breaks
if(elem === 'span') if (elem === 'span')
result += '<br />'; result += '<br />';
return result; return result;
}).join(''); }).join('');
@ -70,6 +77,48 @@ export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html)); return ContentState.createFromBlockArray(convertFromHTML(html));
} }
function unicodeToEmojiUri(str) {
let replaceWith, unicode, alt;
if ((!emojione.unicodeAlt) || (emojione.sprites)) {
// if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames
let mappedUnicode = emojione.mapUnicodeToShort();
}
str = str.replace(emojione.regUnicode, function(unicodeChar) {
if ( (typeof unicodeChar === 'undefined') || (unicodeChar === '') || (!(unicodeChar in emojione.jsEscapeMap)) ) {
// if the unicodeChar doesnt exist just return the entire match
return unicodeChar;
} else {
// get the unicode codepoint from the actual char
unicode = emojione.jsEscapeMap[unicodeChar];
return emojione.imagePathSVG+unicode+'.svg'+emojione.cacheBustParam;
}
});
return str;
}
// Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
let uri = unicodeToEmojiUri(props.children[0].props.text);
let shortname = emojione.toShort(props.children[0].props.text);
let style = {
display: 'inline-block',
width: '1em',
maxHeight: '1em',
background: `url(${uri})`,
backgroundSize: 'contain',
backgroundPosition: 'center center',
overflow: 'hidden',
};
return (<span title={shortname} style={style}><span style={{opacity: 0}}>{props.children}</span></span>);
},
};
/** /**
* Returns a composite decorator which has access to provided scope. * Returns a composite decorator which has access to provided scope.
*/ */
@ -85,9 +134,10 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
// unused until we make these decorators immutable (autocomplete needed) // unused until we make these decorators immutable (autocomplete needed)
let name = member ? member.name : null; let name = member ? member.name : null;
let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null; let avatar = member ? <MemberAvatar member={member} width={16} height={16}/> : null;
return <span className="mx_UserPill">{avatar} {props.children}</span>; return <span className="mx_UserPill">{avatar}{props.children}</span>;
} }
}; };
let roomDecorator = { let roomDecorator = {
strategy: (contentBlock, callback) => { strategy: (contentBlock, callback) => {
findWithRegex(ROOM_REGEX, contentBlock, callback); findWithRegex(ROOM_REGEX, contentBlock, callback);
@ -97,7 +147,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
} }
}; };
return [usernameDecorator, roomDecorator]; return [usernameDecorator, roomDecorator, emojiDecorator];
} }
export function getScopedMDDecorators(scope: any): CompositeDecorator { export function getScopedMDDecorators(scope: any): CompositeDecorator {
@ -123,6 +173,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
</a> </a>
) )
}); });
markdownDecorators.push(emojiDecorator);
return markdownDecorators; return markdownDecorators;
} }
@ -153,7 +204,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
text = ""; text = "";
for(let currentKey = startKey; for (let currentKey = startKey;
currentKey && currentKey !== endKey; currentKey && currentKey !== endKey;
currentKey = contentState.getKeyAfter(currentKey)) { currentKey = contentState.getKeyAfter(currentKey)) {
let blockText = getText(currentKey); let blockText = getText(currentKey);
@ -168,3 +219,106 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey);
} }
/**
* Computes the plaintext offsets of the given SelectionState.
* Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc)
* Used by autocomplete to show completions when the current selection lies within, or at the edges of a command.
*/
export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0;
for (let block of contentBlocks) {
if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset();
}
if (selectionState.getEndKey() === block.getKey()) {
end = offset + selectionState.getEndOffset();
break;
}
offset += block.getLength();
}
return {
start,
end,
};
}
export function textOffsetsToSelectionState({start, end}: {start: number, end: number},
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
for (let block of contentBlocks) {
let blockLength = block.getLength();
if (start !== -1 && start < blockLength) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
anchorOffset: start,
});
start = -1;
} else {
start -= blockLength;
}
if (end !== -1 && end <= blockLength) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
focusOffset: end,
});
end = -1;
} else {
end -= blockLength;
}
}
return selectionState;
}
// modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js
export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState {
const contentState = editorState.getCurrentContent();
const blocks = contentState.getBlockMap();
let newContentState = contentState;
blocks.forEach((block) => {
const plainText = block.getText();
const addEntityToEmoji = (start, end) => {
const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity
const entity = Entity.get(existingEntityKey);
if (entity && entity.get('type') === 'emoji') {
return;
}
}
const selection = SelectionState.createEmpty(block.getKey())
.set('anchorOffset', start)
.set('focusOffset', end);
const emojiText = plainText.substring(start, end);
const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText });
newContentState = Modifier.replaceText(
newContentState,
selection,
emojiText,
null,
entityKey,
);
};
findWithRegex(EMOJI_REGEX, block, addEntityToEmoji);
});
if (!newContentState.equals(contentState)) {
return EditorState.push(
editorState,
newContentState,
'convert-to-immutable-emojis',
);
}
return editorState;
}

View File

@ -152,7 +152,10 @@ class Register extends Signup {
console.log("Active flow => %s", JSON.stringify(flow)); console.log("Active flow => %s", JSON.stringify(flow));
var flowStage = self.firstUncompletedStage(flow); var flowStage = self.firstUncompletedStage(flow);
if (flowStage != self.activeStage) { if (flowStage != self.activeStage) {
return self.startStage(flowStage); return self.startStage(flowStage).catch(function(err) {
self.setStep('START');
throw err;
});
} }
} }
} }

View File

@ -170,7 +170,7 @@ class EmailIdentityStage extends Stage {
encodeURIComponent(this.signupInstance.getServerData().session); encodeURIComponent(this.signupInstance.getServerData().session);
var self = this; var self = this;
return this.client.requestEmailToken( return this.client.requestRegisterEmailToken(
this.signupInstance.email, this.signupInstance.email,
this.clientSecret, this.clientSecret,
1, // TODO: Multiple send attempts? 1, // TODO: Multiple send attempts?
@ -186,8 +186,8 @@ class EmailIdentityStage extends Stage {
var e = { var e = {
isFatal: true isFatal: true
}; };
if (error.errcode == 'THREEPID_IN_USE') { if (error.errcode == 'M_THREEPID_IN_USE') {
e.message = "Email in use"; e.message = "This email address is already registered";
} else { } else {
e.message = 'Unable to contact the given identity server'; e.message = 'Unable to contact the given identity server';
} }

View File

@ -17,7 +17,6 @@ limitations under the License.
var MatrixClientPeg = require("./MatrixClientPeg"); var MatrixClientPeg = require("./MatrixClientPeg");
var MatrixTools = require("./MatrixTools"); var MatrixTools = require("./MatrixTools");
var dis = require("./dispatcher"); var dis = require("./dispatcher");
var encryption = require("./encryption");
var Tinter = require("./Tinter"); var Tinter = require("./Tinter");
@ -82,32 +81,13 @@ var commands = {
return success( return success(
MatrixClientPeg.get().setRoomAccountData( MatrixClientPeg.get().setRoomAccountData(
room_id, "org.matrix.room.color_scheme", colorScheme room_id, "org.matrix.room.color_scheme", colorScheme
) )
); );
} }
} }
return reject(this.getUsage()); return reject(this.getUsage());
}), }),
encrypt: new Command("encrypt", "<on|off>", function(room_id, args) {
if (args == "on") {
var client = MatrixClientPeg.get();
var members = client.getRoom(room_id).currentState.members;
var user_ids = Object.keys(members);
return success(
encryption.enableEncryption(client, room_id, user_ids)
);
}
if (args == "off") {
var client = MatrixClientPeg.get();
return success(
encryption.disableEncryption(client, room_id)
);
}
return reject(this.getUsage());
}),
// Change the room topic // Change the room topic
topic: new Command("topic", "<topic>", function(room_id, args) { topic: new Command("topic", "<topic>", function(room_id, args) {
if (args) { if (args) {
@ -132,46 +112,25 @@ var commands = {
}), }),
// Join a room // Join a room
join: new Command("join", "<room_alias>", function(room_id, args) { join: new Command("join", "#alias:domain", function(room_id, args) {
if (args) { if (args) {
var matches = args.match(/^(\S+)$/); var matches = args.match(/^(\S+)$/);
if (matches) { if (matches) {
var room_alias = matches[1]; var room_alias = matches[1];
if (room_alias[0] !== '#') { if (room_alias[0] !== '#') {
return reject("Usage: /join #alias:domain"); return reject(this.getUsage());
} }
if (!room_alias.match(/:/)) { if (!room_alias.match(/:/)) {
room_alias += ':' + MatrixClientPeg.get().getDomain(); room_alias += ':' + MatrixClientPeg.get().getDomain();
} }
// Try to find a room with this alias dis.dispatch({
// XXX: do we need to do this? Doesn't the JS SDK suppress duplicate attempts to join the same room? action: 'view_room',
var foundRoom = MatrixTools.getRoomForAlias( room_alias: room_alias,
MatrixClientPeg.get().getRooms(), auto_join: true,
room_alias });
);
if (foundRoom) { // we've already joined this room, view it if it's not archived. return success();
var me = foundRoom.getMember(MatrixClientPeg.get().credentials.userId);
if (me && me.membership !== "leave") {
dis.dispatch({
action: 'view_room',
room_id: foundRoom.roomId
});
return success();
}
}
// otherwise attempt to join this alias.
return success(
MatrixClientPeg.get().joinRoom(room_alias).then(
function(room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId
});
})
);
} }
} }
return reject(this.getUsage()); return reject(this.getUsage());

View File

@ -13,7 +13,10 @@ 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 Entry = require("./TabCompleteEntries").Entry;
import { Entry, MemberEntry, CommandEntry } from './TabCompleteEntries';
import SlashCommands from './SlashCommands';
import MatrixClientPeg from './MatrixClientPeg';
const DELAY_TIME_MS = 1000; const DELAY_TIME_MS = 1000;
const KEY_TAB = 9; const KEY_TAB = 9;
@ -45,23 +48,39 @@ class TabComplete {
this.isFirstWord = false; // true if you tab-complete on the first word this.isFirstWord = false; // true if you tab-complete on the first word
this.enterTabCompleteTimerId = null; this.enterTabCompleteTimerId = null;
this.inPassiveMode = false; this.inPassiveMode = false;
// Map tracking ordering of the room members.
// userId: integer, highest comes first.
this.memberTabOrder = {};
// monotonically increasing counter used for tracking ordering of members
this.memberOrderSeq = 0;
} }
/** /**
* @param {Entry[]} completeList * Call this when a a UI element representing a tab complete entry has been clicked
* @param {entry} The entry that was clicked
*/ */
setCompletionList(completeList) { onEntryClick(entry) {
this.list = completeList;
if (this.opts.onClickCompletes) { if (this.opts.onClickCompletes) {
// assign onClick listeners for each entry to complete the text this.completeTo(entry);
this.list.forEach((l) => {
l.onClick = () => {
this.completeTo(l);
}
});
} }
} }
loadEntries(room) {
this._makeEntries(room);
this._initSorting(room);
this._sortEntries();
}
onMemberSpoke(member) {
if (this.memberTabOrder[member.userId] === undefined) {
this.list.push(new MemberEntry(member));
}
this.memberTabOrder[member.userId] = this.memberOrderSeq++;
this._sortEntries();
}
/** /**
* @param {DOMElement} * @param {DOMElement}
*/ */
@ -307,6 +326,54 @@ class TabComplete {
this.opts.onStateChange(this.completing); this.opts.onStateChange(this.completing);
} }
} }
_sortEntries() {
// largest comes first
const KIND_ORDER = {
command: 1,
member: 2,
};
this.list.sort((a, b) => {
const kindOrderDifference = KIND_ORDER[b.kind] - KIND_ORDER[a.kind];
if (kindOrderDifference != 0) {
return kindOrderDifference;
}
if (a.kind == 'member') {
let orderA = this.memberTabOrder[a.member.userId];
let orderB = this.memberTabOrder[b.member.userId];
if (orderA === undefined) orderA = -1;
if (orderB === undefined) orderB = -1;
return orderB - orderA;
}
// anything else we have no ordering for
return 0;
});
}
_makeEntries(room) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
this.list = MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
);
}
_initSorting(room) {
this.memberTabOrder = {};
this.memberOrderSeq = 0;
for (const ev of room.getLiveTimeline().getEvents()) {
this.memberTabOrder[ev.getSender()] = this.memberOrderSeq++;
}
}
}; };
module.exports = TabComplete; module.exports = TabComplete;

View File

@ -69,6 +69,7 @@ class Entry {
class CommandEntry extends Entry { class CommandEntry extends Entry {
constructor(cmd, cmdWithArgs) { constructor(cmd, cmdWithArgs) {
super(cmdWithArgs); super(cmdWithArgs);
this.kind = 'command';
this.cmd = cmd; this.cmd = cmd;
} }
@ -95,6 +96,7 @@ class MemberEntry extends Entry {
constructor(member) { constructor(member) {
super(member.name || member.userId); super(member.name || member.userId);
this.member = member; this.member = member;
this.kind = 'member';
} }
getImageJsx() { getImageJsx() {
@ -114,24 +116,7 @@ class MemberEntry extends Entry {
} }
MemberEntry.fromMemberList = function(members) { MemberEntry.fromMemberList = function(members) {
return members.sort(function(a, b) { return members.map(function(m) {
var userA = a.user;
var userB = b.user;
if (userA && !userB) {
return -1; // a comes first
}
else if (!userA && userB) {
return 1; // b comes first
}
else if (!userA && !userB) {
return 0; // don't care
}
else { // both User objects exist
var lastActiveAgoA = userA.lastActiveAgo || Number.MAX_SAFE_INTEGER;
var lastActiveAgoB = userB.lastActiveAgo || Number.MAX_SAFE_INTEGER;
return lastActiveAgoA - lastActiveAgoB;
}
}).map(function(m) {
return new MemberEntry(m); return new MemberEntry(m);
}); });
} }

View File

@ -113,6 +113,35 @@ module.exports = {
}); });
}, },
getUrlPreviewsDisabled: function() {
var event = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls");
return (event && event.getContent().disable);
},
setUrlPreviewsDisabled: function(disabled) {
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", {
disable: disabled
});
},
getSyncedSettings: function() {
var event = MatrixClientPeg.get().getAccountData("im.vector.web.settings");
return event ? event.getContent() : {};
},
getSyncedSetting: function(type) {
var settings = this.getSyncedSettings();
return settings[type];
},
setSyncedSetting: function(type, value) {
var settings = this.getSyncedSettings();
settings[type] = value;
// FIXME: handle errors
return MatrixClientPeg.get().setAccountData("im.vector.web.settings", settings);
},
isFeatureEnabled: function(feature: string): boolean { isFeatureEnabled: function(feature: string): boolean {
return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true'; return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true';
}, },

View File

@ -18,6 +18,19 @@ module.exports = React.createClass({
// optional transition information for changing existing children // optional transition information for changing existing children
transition: React.PropTypes.object, transition: React.PropTypes.object,
// a list of state objects to apply to each child node in turn
startStyles: React.PropTypes.array,
// a list of transition options from the corresponding startStyle
enterTransitionOpts: React.PropTypes.array,
},
getDefaultProps: function() {
return {
startStyles: [],
enterTransitionOpts: [],
};
}, },
componentWillMount: function() { componentWillMount: function() {
@ -56,56 +69,71 @@ module.exports = React.createClass({
} }
self.children[c.key] = old; self.children[c.key] = old;
} else { } else {
// new element. If it has a startStyle, use that as the style and go through // new element. If we have a startStyle, use that as the style and go through
// the enter animations // the enter animations
var newProps = { var newProps = {};
ref: self.collectNode.bind(self, c.key) var restingStyle = c.props.style;
};
if (c.props.startStyle && Object.keys(c.props.startStyle).length) { var startStyles = self.props.startStyles;
var startStyle = c.props.startStyle; if (startStyles.length > 0) {
if (Array.isArray(startStyle)) { var startStyle = startStyles[0]
startStyle = startStyle[0];
}
newProps._restingStyle = c.props.style;
newProps.style = startStyle; newProps.style = startStyle;
//console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); // console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
// apply the enter animations once it's mounted
} }
newProps.ref = (n => self._collectNode(
c.key, n, restingStyle
));
self.children[c.key] = React.cloneElement(c, newProps); self.children[c.key] = React.cloneElement(c, newProps);
} }
}); });
}, },
collectNode: function(k, node) { /**
* called when a child element is mounted/unmounted
*
* @param {string} k key of the child
* @param {null|Object} node On mount: React node. On unmount: null
* @param {Object} restingStyle final style
*/
_collectNode: function(k, node, restingStyle) {
if ( if (
node && node &&
this.nodes[k] === undefined && this.nodes[k] === undefined &&
node.props.startStyle && this.props.startStyles.length > 0
Object.keys(node.props.startStyle).length
) { ) {
var startStyles = this.props.startStyles;
var transitionOpts = this.props.enterTransitionOpts;
var domNode = ReactDom.findDOMNode(node); var domNode = ReactDom.findDOMNode(node);
var startStyles = node.props.startStyle;
var transitionOpts = node.props.enterTransitionOpts;
if (!Array.isArray(startStyles)) {
startStyles = [ startStyles ];
transitionOpts = [ transitionOpts ];
}
// 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) {
Velocity(domNode, startStyles[i], transitionOpts[i-1]); Velocity(domNode, startStyles[i], transitionOpts[i-1]);
//console.log("start: "+JSON.stringify(startStyles[i])); /*
console.log("start:",
JSON.stringify(transitionOpts[i-1]),
"->",
JSON.stringify(startStyles[i]),
);
*/
} }
// and then we animate to the resting state // and then we animate to the resting state
Velocity(domNode, node.props._restingStyle, Velocity(domNode, restingStyle,
transitionOpts[i-1]) transitionOpts[i-1])
.then(() => { .then(() => {
// once we've reached the resting state, hide the element if // once we've reached the resting state, hide the element if
// appropriate // appropriate
domNode.style.visibility = node.props._restingStyle.visibility; domNode.style.visibility = restingStyle.visibility;
}); });
//console.log("enter: "+JSON.stringify(node.props._restingStyle)); /*
console.log("enter:",
JSON.stringify(transitionOpts[i-1]),
"->",
JSON.stringify(restingStyle));
*/
} else if (node === null) { } else if (node === null) {
// Velocity stores data on elements using the jQuery .data() // Velocity stores data on elements using the jQuery .data()
// method, and assumes you'll be using jQuery's .remove() to // method, and assumes you'll be using jQuery's .remove() to

View File

@ -0,0 +1,54 @@
import Q from 'q';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) {
if(commandRegex) {
if(!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
}
this.commandRegex = commandRegex;
}
}
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/
getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array<string> {
if (this.commandRegex == null) {
return null;
}
this.commandRegex.lastIndex = 0;
let match;
while ((match = this.commandRegex.exec(query)) != null) {
let matchStart = match.index,
matchEnd = matchStart + match[0].length;
if (selection.start <= matchEnd && selection.end >= matchStart) {
return {
command: match,
range: {
start: matchStart,
end: matchEnd,
},
};
}
}
return {
command: null,
range: {
start: -1,
end: -1,
},
};
}
getCompletions(query: string, selection: {start: number, end: number}) {
return Q.when([]);
}
getName(): string {
return 'Default Provider';
}
}

View File

@ -0,0 +1,22 @@
import CommandProvider from './CommandProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
const PROVIDERS = [
UserProvider,
CommandProvider,
DuckDuckGoProvider,
RoomProvider,
EmojiProvider,
].map(completer => completer.getInstance());
export function getCompletions(query: string, selection: {start: number, end: number}) {
return PROVIDERS.map(provider => {
return {
completions: provider.getCompletions(query, selection),
provider,
};
});
}

View File

@ -0,0 +1,86 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
const COMMANDS = [
{
command: '/me',
args: '<message>',
description: 'Displays action',
},
{
command: '/ban',
args: '<user-id> [reason]',
description: 'Bans user with given id',
},
{
command: '/deop',
args: '<user-id>',
description: 'Deops user with given id',
},
{
command: '/invite',
args: '<user-id>',
description: 'Invites user with given id to current room'
},
{
command: '/join',
args: '<room-alias>',
description: 'Joins room with given alias',
},
{
command: '/kick',
args: '<user-id> [reason]',
description: 'Kicks user with given id',
},
{
command: '/nick',
args: '<display-name>',
description: 'Changes your display nickname',
},
];
let COMMAND_RE = /(^\/\w*)/g;
let instance = null;
export default class CommandProvider extends AutocompleteProvider {
constructor() {
super(COMMAND_RE);
this.fuse = new Fuse(COMMANDS, {
keys: ['command', 'args', 'description'],
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
return {
completion: result.command + ' ',
component: (<TextualCompletion
title={result.command}
subtitle={result.args}
description={result.description}
/>),
range,
};
});
}
return Q.when(completions);
}
getName() {
return 'Commands';
}
static getInstance(): CommandProvider {
if (instance == null)
instance = new CommandProvider();
return instance;
}
}

View File

@ -0,0 +1,19 @@
import React from 'react';
export function TextualCompletion({
title,
subtitle,
description,
}: {
title: ?string,
subtitle: ?string,
description: ?string
}) {
return (
<div style={{width: '100%'}}>
<span>{title}</span>
<em>{subtitle}</em>
<span style={{color: 'gray', float: 'right'}}>{description}</span>
</div>
);
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import 'whatwg-fetch';
import {TextualCompletion} from './Components';
const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector';
let instance = null;
export default class DuckDuckGoProvider extends AutocompleteProvider {
constructor() {
super(DDG_REGEX);
}
static getQueryUri(query: String) {
return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}`
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
getCompletions(query: string, selection: {start: number, end: number}) {
let {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return Q.when([]);
}
return fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
method: 'GET',
})
.then(response => response.json())
.then(json => {
let results = json.Results.map(result => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
});
}
getName() {
return 'Results from DuckDuckGo';
}
static getInstance(): DuckDuckGoProvider {
if (instance == null) {
instance = new DuckDuckGoProvider();
}
return instance;
}
}

View File

@ -0,0 +1,48 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js';
const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
let instance = null;
export default class EmojiProvider extends AutocompleteProvider {
constructor() {
super(EMOJI_REGEX);
this.fuse = new Fuse(EMOJI_SHORTNAMES);
}
getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
completions = this.fuse.search(command[0]).map(result => {
let shortname = EMOJI_SHORTNAMES[result];
let imageHTML = shortnameToImage(shortname);
return {
completion: shortnameToUnicode(shortname),
component: (
<div className="mx_Autocomplete_Completion">
<span dangerouslySetInnerHTML={{__html: imageHTML}}></span> {shortname}
</div>
),
range,
};
}).slice(0, 4);
}
return Q.when(completions);
}
getName() {
return 'Emoji';
}
static getInstance() {
if (instance == null)
instance = new EmojiProvider();
return instance;
}
}

View File

@ -0,0 +1,62 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
import {getDisplayAliasForRoom} from '../MatrixTools';
const ROOM_REGEX = /(?=#)([^\s]*)/g;
let instance = null;
export default class RoomProvider extends AutocompleteProvider {
constructor() {
super(ROOM_REGEX, {
keys: ['displayName', 'userId'],
});
this.fuse = new Fuse([], {
keys: ['name', 'roomId', 'aliases'],
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
let client = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => {
return {
room: room,
name: room.name,
roomId: room.roomId,
aliases: room.getAliases(),
};
}));
completions = this.fuse.search(command[0]).map(room => {
let displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return {
completion: displayAlias,
component: (
<TextualCompletion title={room.name} description={displayAlias} />
),
range,
};
}).slice(0, 4);
}
return Q.when(completions);
}
getName() {
return 'Rooms';
}
static getInstance() {
if (instance == null) {
instance = new RoomProvider();
}
return instance;
}
}

View File

@ -0,0 +1,57 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
const USER_REGEX = /@[^\s]*/g;
let instance = null;
export default class UserProvider extends AutocompleteProvider {
constructor() {
super(USER_REGEX, {
keys: ['name', 'userId'],
});
this.users = [];
this.fuse = new Fuse([], {
keys: ['name', 'userId'],
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
this.fuse.set(this.users);
completions = this.fuse.search(command[0]).map(user => {
const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
return {
completion: user.userId,
component: (
<TextualCompletion
title={displayName}
description={user.userId} />
),
range
};
}).slice(0, 4);
}
return Q.when(completions);
}
getName() {
return 'Users';
}
setUserList(users) {
this.users = users;
}
static getInstance(): UserProvider {
if (instance == null) {
instance = new UserProvider();
}
return instance;
}
}

View File

@ -25,6 +25,7 @@ limitations under the License.
*/ */
module.exports.components = {}; module.exports.components = {};
module.exports.components['structures.ContextualMenu'] = require('./components/structures/ContextualMenu');
module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat');
module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel'); module.exports.components['structures.MessagePanel'] = require('./components/structures/MessagePanel');
@ -51,6 +52,7 @@ module.exports.components['views.dialogs.QuestionDialog'] = require('./component
module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog'); module.exports.components['views.dialogs.SetDisplayNameDialog'] = require('./components/views/dialogs/SetDisplayNameDialog');
module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog'); module.exports.components['views.dialogs.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog');
module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText');
module.exports.components['views.elements.EditableTextContainer'] = require('./components/views/elements/EditableTextContainer');
module.exports.components['views.elements.PowerSelector'] = require('./components/views/elements/PowerSelector'); module.exports.components['views.elements.PowerSelector'] = require('./components/views/elements/PowerSelector');
module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar'); module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar');
module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg'); module.exports.components['views.elements.TintableSvg'] = require('./components/views/elements/TintableSvg');
@ -74,6 +76,8 @@ module.exports.components['views.messages.TextualEvent'] = require('./components
module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody');
module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings'); module.exports.components['views.room_settings.AliasSettings'] = require('./components/views/room_settings/AliasSettings');
module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings'); module.exports.components['views.room_settings.ColorSettings'] = require('./components/views/room_settings/ColorSettings');
module.exports.components['views.room_settings.UrlPreviewSettings'] = require('./components/views/room_settings/UrlPreviewSettings');
module.exports.components['views.rooms.Autocomplete'] = require('./components/views/rooms/Autocomplete');
module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel'); module.exports.components['views.rooms.AuxPanel'] = require('./components/views/rooms/AuxPanel');
module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile'); module.exports.components['views.rooms.EntityTile'] = require('./components/views/rooms/EntityTile');
module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile');
@ -104,6 +108,8 @@ module.exports.components['views.rooms.UserTile'] = require('./components/views/
module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar'); module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword');
module.exports.components['views.settings.DevicesPanel'] = require('./components/views/settings/DevicesPanel');
module.exports.components['views.settings.DevicesPanelEntry'] = require('./components/views/settings/DevicesPanelEntry');
module.exports.components['views.settings.EnableNotificationsButton'] = require('./components/views/settings/EnableNotificationsButton'); module.exports.components['views.settings.EnableNotificationsButton'] = require('./components/views/settings/EnableNotificationsButton');
module.exports.components['views.voip.CallView'] = require('./components/views/voip/CallView'); module.exports.components['views.voip.CallView'] = require('./components/views/voip/CallView');
module.exports.components['views.voip.IncomingCallBox'] = require('./components/views/voip/IncomingCallBox'); module.exports.components['views.voip.IncomingCallBox'] = require('./components/views/voip/IncomingCallBox');

View File

@ -17,6 +17,7 @@ limitations under the License.
'use strict'; 'use strict';
var classNames = require('classnames');
var React = require('react'); var React = require('react');
var ReactDOM = require('react-dom'); var ReactDOM = require('react-dom');
@ -27,6 +28,12 @@ var ReactDOM = require('react-dom');
module.exports = { module.exports = {
ContextualMenuContainerId: "mx_ContextualMenu_Container", ContextualMenuContainerId: "mx_ContextualMenu_Container",
propTypes: {
menuWidth: React.PropTypes.number,
menuHeight: React.PropTypes.number,
chevronOffset: React.PropTypes.number,
},
getOrCreateContainer: function() { getOrCreateContainer: function() {
var container = document.getElementById(this.ContextualMenuContainerId); var container = document.getElementById(this.ContextualMenuContainerId);
@ -45,29 +52,50 @@ module.exports = {
var closeMenu = function() { var closeMenu = function() {
ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); ReactDOM.unmountComponentAtNode(self.getOrCreateContainer());
if (props && props.onFinished) props.onFinished.apply(null, arguments); if (props && props.onFinished) {
props.onFinished.apply(null, arguments);
}
}; };
var position = { var position = {
top: props.top - 20, top: props.top,
}; };
var chevronOffset = {
top: props.chevronOffset,
}
var chevron = null; var chevron = null;
if (props.left) { if (props.left) {
chevron = <img className="mx_ContextualMenu_chevron_left" src="img/chevron-left.png" width="9" height="16" /> chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_left"></div>
position.left = props.left + 8; position.left = props.left;
} else { } else {
chevron = <img className="mx_ContextualMenu_chevron_right" src="img/chevron-right.png" width="9" height="16" /> chevron = <div style={chevronOffset} className="mx_ContextualMenu_chevron_right"></div>
position.right = props.right + 8; position.right = props.right;
} }
var className = 'mx_ContextualMenu_wrapper'; var className = 'mx_ContextualMenu_wrapper';
var menuClasses = classNames({
'mx_ContextualMenu': true,
'mx_ContextualMenu_left': props.left,
'mx_ContextualMenu_right': !props.left,
});
var menuSize = {};
if (props.menuWidth) {
menuSize.width = props.menuWidth;
}
if (props.menuHeight) {
menuSize.height = props.menuHeight;
}
// FIXME: If a menu uses getDefaultProps it clobbers the onFinished // FIXME: If a menu uses getDefaultProps it clobbers the onFinished
// property set here so you can't close the menu from a button click! // property set here so you can't close the menu from a button click!
var menu = ( var menu = (
<div className={className}> <div className={className} style={position}>
<div className="mx_ContextualMenu" style={position}> <div className={menuClasses} style={menuSize}>
{chevron} {chevron}
<Element {...props} onFinished={closeMenu}/> <Element {...props} onFinished={closeMenu}/>
</div> </div>

View File

@ -24,7 +24,6 @@ var PresetValues = {
Custom: "custom", Custom: "custom",
}; };
var q = require('q'); var q = require('q');
var encryption = require("../../encryption");
var sdk = require('../../index'); var sdk = require('../../index');
module.exports = React.createClass({ module.exports = React.createClass({
@ -108,17 +107,8 @@ module.exports = React.createClass({
var deferred = cli.createRoom(options); var deferred = cli.createRoom(options);
var response;
if (this.state.encrypt) { if (this.state.encrypt) {
deferred = deferred.then(function(res) { // TODO
response = res;
return encryption.enableEncryption(
cli, response.room_id, options.invite
);
}).then(function() {
return q(response) }
);
} }
this.setState({ this.setState({

View File

@ -21,7 +21,7 @@ var Favico = require('favico.js');
var MatrixClientPeg = require("../../MatrixClientPeg"); var MatrixClientPeg = require("../../MatrixClientPeg");
var SdkConfig = require("../../SdkConfig"); var SdkConfig = require("../../SdkConfig");
var Notifier = require("../../Notifier"); var Notifier = require("../../Notifier");
var ContextualMenu = require("../../ContextualMenu"); var ContextualMenu = require("./ContextualMenu");
var RoomListSorter = require("../../RoomListSorter"); var RoomListSorter = require("../../RoomListSorter");
var UserActivity = require("../../UserActivity"); var UserActivity = require("../../UserActivity");
var Presence = require("../../Presence"); var Presence = require("../../Presence");
@ -37,6 +37,7 @@ var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools'); var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix"); var linkifyMatrix = require("../../linkify-matrix");
var KeyCode = require('../../KeyCode'); var KeyCode = require('../../KeyCode');
var Lifecycle = require('../../Lifecycle');
var createRoom = require("../../createRoom"); var createRoom = require("../../createRoom");
@ -109,10 +110,14 @@ module.exports = React.createClass({
return window.localStorage.getItem("mx_hs_url"); return window.localStorage.getItem("mx_hs_url");
} }
else { else {
return this.props.config.default_hs_url || "https://matrix.org"; return this.getDefaultHsUrl();
} }
}, },
getDefaultHsUrl() {
return this.props.config.default_hs_url || "https://matrix.org";
},
getFallbackHsUrl: function() { getFallbackHsUrl: function() {
return this.props.config.fallback_hs_url; return this.props.config.fallback_hs_url;
}, },
@ -127,16 +132,31 @@ module.exports = React.createClass({
return window.localStorage.getItem("mx_is_url"); return window.localStorage.getItem("mx_is_url");
} }
else { else {
return this.props.config.default_is_url || "https://vector.im" return this.getDefaultIsUrl();
} }
}, },
getDefaultIsUrl() {
return this.props.config.default_is_url || "https://vector.im";
},
componentWillMount: function() { componentWillMount: function() {
SdkConfig.put(this.props.config); SdkConfig.put(this.props.config);
this.favicon = new Favico({animation: 'none'}); this.favicon = new Favico({animation: 'none'});
// Stashed guest credentials if the user logs out
// whilst logged in as a guest user (so they can change
// their mind & log back in)
this.guestCreds = null;
if (this.props.config.sync_timeline_limit) {
MatrixClientPeg.opts.initialSyncLimit = this.props.config.sync_timeline_limit;
}
}, },
componentDidMount: function() { componentDidMount: function() {
let clientStarted = false;
this._autoRegisterAsGuest = false; this._autoRegisterAsGuest = false;
if (this.props.enableGuest) { if (this.props.enableGuest) {
if (!this.getCurrentHsUrl()) { if (!this.getCurrentHsUrl()) {
@ -150,13 +170,14 @@ module.exports = React.createClass({
this.props.startingQueryParams.guest_access_token) this.props.startingQueryParams.guest_access_token)
{ {
this._autoRegisterAsGuest = false; this._autoRegisterAsGuest = false;
this.onLoggedIn({ Lifecycle.setLoggedIn({
userId: this.props.startingQueryParams.guest_user_id, userId: this.props.startingQueryParams.guest_user_id,
accessToken: this.props.startingQueryParams.guest_access_token, accessToken: this.props.startingQueryParams.guest_access_token,
homeserverUrl: this.props.config.default_hs_url, homeserverUrl: this.getDefaultHsUrl(),
identityServerUrl: this.props.config.default_is_url, identityServerUrl: this.getDefaultIsUrl(),
guest: true guest: true
}); });
clientStarted = true;
} }
else { else {
this._autoRegisterAsGuest = true; this._autoRegisterAsGuest = true;
@ -168,7 +189,9 @@ module.exports = React.createClass({
// Don't auto-register as a guest. This applies if you refresh the page on a // Don't auto-register as a guest. This applies if you refresh the page on a
// logged in client THEN hit the Sign Out button. // logged in client THEN hit the Sign Out button.
this._autoRegisterAsGuest = false; this._autoRegisterAsGuest = false;
this.startMatrixClient(); if (!clientStarted) {
Lifecycle.startMatrixClient();
}
} }
this.focusComposer = false; this.focusComposer = false;
// scrollStateMap is a map from room id to the scroll state returned by // scrollStateMap is a map from room id to the scroll state returned by
@ -223,7 +246,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().registerGuest().done(function(creds) { MatrixClientPeg.get().registerGuest().done(function(creds) {
console.log("Registered as guest: %s", creds.user_id); console.log("Registered as guest: %s", creds.user_id);
self._setAutoRegisterAsGuest(false); self._setAutoRegisterAsGuest(false);
self.onLoggedIn({ Lifecycle.setLoggedIn({
userId: creds.user_id, userId: creds.user_id,
accessToken: creds.access_token, accessToken: creds.access_token,
homeserverUrl: hsUrl, homeserverUrl: hsUrl,
@ -254,34 +277,10 @@ module.exports = React.createClass({
var self = this; var self = this;
switch (payload.action) { switch (payload.action) {
case 'logout': case 'logout':
var guestCreds;
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
guestCreds = { // stash our guest creds so we can backout if needed this.guestCreds = MatrixClientPeg.getCredentials();
userId: MatrixClientPeg.get().credentials.userId,
accessToken: MatrixClientPeg.get().getAccessToken(),
homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(),
identityServerUrl: MatrixClientPeg.get().getIdentityServerUrl(),
guest: true
}
} }
Lifecycle.logout();
if (window.localStorage) {
var hsUrl = this.getCurrentHsUrl();
var isUrl = this.getCurrentIsUrl();
window.localStorage.clear();
// preserve our HS & IS URLs for convenience
// N.B. we cache them in hsUrl/isUrl and can't really inline them
// as getCurrentHsUrl() may call through to localStorage.
window.localStorage.setItem("mx_hs_url", hsUrl);
window.localStorage.setItem("mx_is_url", isUrl);
}
this._stopMatrixClient();
this.notifyNewScreen('login');
this.replaceState({
logged_in: false,
ready: false,
guestCreds: guestCreds,
});
break; break;
case 'start_registration': case 'start_registration':
var newState = payload.params || {}; var newState = payload.params || {};
@ -307,7 +306,6 @@ module.exports = React.createClass({
if (this.state.logged_in) return; if (this.state.logged_in) return;
this.replaceState({ this.replaceState({
screen: 'login', screen: 'login',
guestCreds: this.state.guestCreds,
}); });
this.notifyNewScreen('login'); this.notifyNewScreen('login');
break; break;
@ -317,17 +315,12 @@ module.exports = React.createClass({
}); });
break; break;
case 'start_upgrade_registration': case 'start_upgrade_registration':
// stash our guest creds so we can backout if needed
this.guestCreds = MatrixClientPeg.getCredentials();
this.replaceState({ this.replaceState({
screen: "register", screen: "register",
upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(), upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(),
guestAccessToken: MatrixClientPeg.get().getAccessToken(), guestAccessToken: MatrixClientPeg.get().getAccessToken(),
guestCreds: { // stash our guest creds so we can backout if needed
userId: MatrixClientPeg.get().credentials.userId,
accessToken: MatrixClientPeg.get().getAccessToken(),
homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(),
identityServerUrl: MatrixClientPeg.get().getIdentityServerUrl(),
guest: true
}
}); });
this.notifyNewScreen('register'); this.notifyNewScreen('register');
break; break;
@ -349,10 +342,13 @@ module.exports = React.createClass({
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
client.loginWithToken(payload.params.loginToken).done(function(data) { client.loginWithToken(payload.params.loginToken).done(function(data) {
MatrixClientPeg.replaceUsingAccessToken( MatrixClientPeg.replaceUsingCreds({
client.getHomeserverUrl(), client.getIdentityServerUrl(), homeserverUrl: client.getHomeserverUrl(),
data.user_id, data.access_token identityServerUrl: client.getIdentityServerUrl(),
); userId: data.user_id,
accessToken: data.access_token,
guest: false,
});
self.setState({ self.setState({
screen: undefined, screen: undefined,
logged_in: true logged_in: true
@ -384,7 +380,7 @@ module.exports = React.createClass({
// FIXME: controller shouldn't be loading a view :( // FIXME: controller shouldn't be loading a view :(
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader); var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(function() { d.then(function() {
modal.close(); modal.close();
@ -405,10 +401,7 @@ module.exports = React.createClass({
// known to be in (eg. user clicks on a room in the recents panel), supply the ID // known to be in (eg. user clicks on a room in the recents panel), supply the ID
// If the user is clicking on a room in the context of the alias being presented // If the user is clicking on a room in the context of the alias being presented
// to them, supply the room alias. If both are supplied, the room ID will be ignored. // to them, supply the room alias. If both are supplied, the room ID will be ignored.
this._viewRoom( this._viewRoom(payload);
payload.room_id, payload.room_alias, payload.show_settings, payload.event_id,
payload.third_party_invite, payload.oob_data
);
break; break;
case 'view_prev_room': case 'view_prev_room':
roomIndexDelta = -1; roomIndexDelta = -1;
@ -425,7 +418,7 @@ module.exports = React.createClass({
} }
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length; roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1; if (roomIndex < 0) roomIndex = allRooms.length - 1;
this._viewRoom(allRooms[roomIndex].roomId); this._viewRoom({ room_id: allRooms[roomIndex].roomId });
break; break;
case 'view_indexed_room': case 'view_indexed_room':
var allRooms = RoomListSorter.mostRecentActivityFirst( var allRooms = RoomListSorter.mostRecentActivityFirst(
@ -433,7 +426,7 @@ module.exports = React.createClass({
); );
var roomIndex = payload.roomIndex; var roomIndex = payload.roomIndex;
if (allRooms[roomIndex]) { if (allRooms[roomIndex]) {
this._viewRoom(allRooms[roomIndex].roomId); this._viewRoom({ room_id: allRooms[roomIndex].roomId });
} }
break; break;
case 'view_user_settings': case 'view_user_settings':
@ -479,6 +472,15 @@ module.exports = React.createClass({
middleOpacity: payload.middleOpacity, middleOpacity: payload.middleOpacity,
}); });
break; break;
case 'on_logged_in':
this._onLoggedIn();
break;
case 'on_logged_out':
this._onLoggedOut();
break;
case 'will_start_client':
this._onWillStartClient();
break;
} }
}, },
@ -493,39 +495,45 @@ module.exports = React.createClass({
// switch view to the given room // switch view to the given room
// //
// eventId is optional and will cause a switch to the context of that // @param {Object} room_info Object containing data about the room to be joined
// particular event. // @param {string=} room_info.room_id ID of the room to join. One of room_id or room_alias must be given.
// @param {Object} thirdPartyInvite Object containing data about the third party // @param {string=} room_info.room_alias Alias of the room to join. One of room_id or room_alias must be given.
// @param {boolean=} room_info.auto_join If true, automatically attempt to join the room if not already a member.
// @param {boolean=} room_info.show_settings Makes RoomView show the room settings dialog.
// @param {string=} room_info.event_id ID of the event in this room to show: this will cause a switch to the
// context of that particular event.
// @param {Object=} room_info.third_party_invite Object containing data about the third party
// we received to join the room, if any. // we received to join the room, if any.
// @param {string} thirdPartyInvite.inviteSignUrl 3pid invite sign URL // @param {string=} room_info.third_party_invite.inviteSignUrl 3pid invite sign URL
// @param {string} thirdPartyInvite.invitedwithEmail The email address the invite was sent to // @param {string=} room_info.third_party_invite.invitedEmail The email address the invite was sent to
// @param {Object} oob_data Object of additional data about the room // @param {Object=} room_info.oob_data Object of additional data about the room
// that has been passed out-of-band (eg. // that has been passed out-of-band (eg.
// room name and avatar from an invite email) // room name and avatar from an invite email)
_viewRoom: function(roomId, roomAlias, showSettings, eventId, thirdPartyInvite, oob_data) { _viewRoom: function(room_info) {
// before we switch room, record the scroll state of the current room // before we switch room, record the scroll state of the current room
this._updateScrollMap(); this._updateScrollMap();
this.focusComposer = true; this.focusComposer = true;
var newState = { var newState = {
initialEventId: eventId, initialEventId: room_info.event_id,
highlightedEventId: eventId, highlightedEventId: room_info.event_id,
initialEventPixelOffset: undefined, initialEventPixelOffset: undefined,
page_type: this.PageTypes.RoomView, page_type: this.PageTypes.RoomView,
thirdPartyInvite: thirdPartyInvite, thirdPartyInvite: room_info.third_party_invite,
roomOobData: oob_data, roomOobData: room_info.oob_data,
currentRoomAlias: roomAlias, currentRoomAlias: room_info.room_alias,
autoJoin: room_info.auto_join,
}; };
if (!roomAlias) { if (!room_info.room_alias) {
newState.currentRoomId = roomId; newState.currentRoomId = room_info.room_id;
} }
// if we aren't given an explicit event id, look for one in the // if we aren't given an explicit event id, look for one in the
// scrollStateMap. // scrollStateMap.
if (!eventId) { if (!room_info.event_id) {
var scrollState = this.scrollStateMap[roomId]; var scrollState = this.scrollStateMap[room_info.room_id];
if (scrollState) { if (scrollState) {
newState.initialEventId = scrollState.focussedEvent; newState.initialEventId = scrollState.focussedEvent;
newState.initialEventPixelOffset = scrollState.pixelOffset; newState.initialEventPixelOffset = scrollState.pixelOffset;
@ -538,8 +546,8 @@ module.exports = React.createClass({
// the new screen yet (we won't be showing it yet) // the new screen yet (we won't be showing it yet)
// The normal case where this happens is navigating // The normal case where this happens is navigating
// to the room in the URL bar on page load. // to the room in the URL bar on page load.
var presentedId = roomAlias || roomId; var presentedId = room_info.room_alias || room_info.room_id;
var room = MatrixClientPeg.get().getRoom(roomId); var room = MatrixClientPeg.get().getRoom(room_info.room_id);
if (room) { if (room) {
var theAlias = MatrixTools.getDisplayAliasForRoom(room); var theAlias = MatrixTools.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias; if (theAlias) presentedId = theAlias;
@ -555,15 +563,15 @@ module.exports = React.createClass({
// Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); // Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
} }
if (eventId) { if (room_info.event_id) {
presentedId += "/"+eventId; presentedId += "/"+room_info.event_id;
} }
this.notifyNewScreen('room/'+presentedId); this.notifyNewScreen('room/'+presentedId);
newState.ready = true; newState.ready = true;
} }
this.setState(newState); this.setState(newState);
if (this.refs.roomView && showSettings) { if (this.refs.roomView && room_info.showSettings) {
this.refs.roomView.showSettings(true); this.refs.roomView.showSettings(true);
} }
}, },
@ -583,23 +591,36 @@ module.exports = React.createClass({
this.scrollStateMap[roomId] = state; this.scrollStateMap[roomId] = state;
}, },
onLoggedIn: function(credentials) { /**
credentials.guest = Boolean(credentials.guest); * Called when a new logged in session has started
console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest); */
MatrixClientPeg.replaceUsingAccessToken( _onLoggedIn: function(credentials) {
credentials.homeserverUrl, credentials.identityServerUrl, this.guestCreds = null;
credentials.userId, credentials.accessToken, credentials.guest this.notifyNewScreen('');
);
this.setState({ this.setState({
screen: undefined, screen: undefined,
logged_in: true logged_in: true,
}); });
this.startMatrixClient();
this.notifyNewScreen('');
}, },
startMatrixClient: function() { /**
* Called when the session is logged out
*/
_onLoggedOut: function() {
this.notifyNewScreen('login');
this.replaceState({
logged_in: false,
ready: false,
});
},
/**
* Called just before the matrix client is started
* (useful for setting listeners)
*/
_onWillStartClient() {
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
var self = this; var self = this;
cli.on('sync', function(state, prevState) { cli.on('sync', function(state, prevState) {
self.updateFavicon(state, prevState); self.updateFavicon(state, prevState);
@ -666,13 +687,6 @@ module.exports = React.createClass({
action: 'logout' action: 'logout'
}); });
}); });
Notifier.start();
UserActivity.start();
Presence.start();
cli.startClient({
pendingEventOrdering: "detached",
initialSyncLimit: this.props.config.sync_timeline_limit || 20,
});
}, },
// stop all the background processes related to the current client // stop all the background processes related to the current client
@ -910,12 +924,14 @@ module.exports = React.createClass({
onReturnToGuestClick: function() { onReturnToGuestClick: function() {
// reanimate our guest login // reanimate our guest login
this.onLoggedIn(this.state.guestCreds); if (this.guestCreds) {
this.setState({ guestCreds: null }); Lifecycle.setLoggedIn(this.guestCreds);
this.guestCreds = null;
}
}, },
onRegistered: function(credentials) { onRegistered: function(credentials) {
this.onLoggedIn(credentials); Lifecycle.setLoggedIn(credentials);
// do post-registration stuff // do post-registration stuff
// This now goes straight to user settings // This now goes straight to user settings
// We use _setPage since if we wait for // We use _setPage since if we wait for
@ -1032,6 +1048,7 @@ module.exports = React.createClass({
<RoomView <RoomView
ref="roomView" ref="roomView"
roomAddress={this.state.currentRoomAlias || this.state.currentRoomId} roomAddress={this.state.currentRoomAlias || this.state.currentRoomId}
autoJoin={this.state.autoJoin}
onRoomIdResolved={this.onRoomIdResolved} onRoomIdResolved={this.onRoomIdResolved}
eventId={this.state.initialEventId} eventId={this.state.initialEventId}
thirdPartyInvite={this.state.thirdPartyInvite} thirdPartyInvite={this.state.thirdPartyInvite}
@ -1111,8 +1128,8 @@ module.exports = React.createClass({
email={this.props.startingQueryParams.email} email={this.props.startingQueryParams.email}
username={this.state.upgradeUsername} username={this.state.upgradeUsername}
guestAccessToken={this.state.guestAccessToken} guestAccessToken={this.state.guestAccessToken}
defaultHsUrl={this.props.config.default_hs_url} defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.props.config.default_is_url} defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand} brand={this.props.config.brand}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
@ -1120,14 +1137,14 @@ module.exports = React.createClass({
onLoggedIn={this.onRegistered} onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick} onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
onCancelClick={ this.state.guestCreds ? this.onReturnToGuestClick : null } onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
/> />
); );
} else if (this.state.screen == 'forgot_password') { } else if (this.state.screen == 'forgot_password') {
return ( return (
<ForgotPassword <ForgotPassword
defaultHsUrl={this.props.config.default_hs_url} defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.props.config.default_is_url} defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick} onComplete={this.onLoginClick}
@ -1136,16 +1153,16 @@ module.exports = React.createClass({
} else { } else {
return ( return (
<Login <Login
onLoggedIn={this.onLoggedIn} onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick} onRegisterClick={this.onRegisterClick}
defaultHsUrl={this.props.config.default_hs_url} defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.props.config.default_is_url} defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()} customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()} customIsUrl={this.getCurrentIsUrl()}
fallbackHsUrl={this.getFallbackHsUrl()} fallbackHsUrl={this.getFallbackHsUrl()}
onForgotPasswordClick={this.onForgotPasswordClick} onForgotPasswordClick={this.onForgotPasswordClick}
onLoginAsGuestClick={this.props.enableGuest && this.props.config && this.props.config.default_hs_url ? this._registerAsGuest.bind(this, true) : undefined} onLoginAsGuestClick={this.props.enableGuest && this.props.config && this._registerAsGuest.bind(this, true)}
onCancelClick={ this.state.guestCreds ? this.onReturnToGuestClick : null } onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
/> />
); );
} }

View File

@ -44,6 +44,9 @@ module.exports = React.createClass({
// ID of an event to highlight. If undefined, no event will be highlighted. // ID of an event to highlight. If undefined, no event will be highlighted.
highlightedEventId: React.PropTypes.string, highlightedEventId: React.PropTypes.string,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
// event after which we should show a read marker // event after which we should show a read marker
readMarkerEventId: React.PropTypes.string, readMarkerEventId: React.PropTypes.string,
@ -365,6 +368,7 @@ module.exports = React.createClass({
onWidgetLoad={this._onWidgetLoad} onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts} readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap} readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting} checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status} eventSendStatus={mxEv.status}
last={last} isSelectedEvent={highlight}/> last={last} isSelectedEvent={highlight}/>

View File

@ -26,9 +26,9 @@ module.exports = React.createClass({
propTypes: { propTypes: {
// the room this statusbar is representing. // the room this statusbar is representing.
room: React.PropTypes.object.isRequired, room: React.PropTypes.object.isRequired,
// a list of TabCompleteEntries.Entry objects // a TabComplete object
tabCompleteEntries: React.PropTypes.array, tabComplete: React.PropTypes.object.isRequired,
// the number of messages which have arrived since we've been scrolled up // the number of messages which have arrived since we've been scrolled up
numUnreadMessages: React.PropTypes.number, numUnreadMessages: React.PropTypes.number,
@ -208,11 +208,11 @@ module.exports = React.createClass({
); );
} }
if (this.props.tabCompleteEntries) { if (this.props.tabComplete.isTabCompleting()) {
return ( return (
<div className="mx_RoomStatusBar_tabCompleteBar"> <div className="mx_RoomStatusBar_tabCompleteBar">
<div className="mx_RoomStatusBar_tabCompleteWrapper"> <div className="mx_RoomStatusBar_tabCompleteWrapper">
<TabCompleteBar entries={this.props.tabCompleteEntries} /> <TabCompleteBar tabComplete={this.props.tabComplete} />
<div className="mx_RoomStatusBar_tabCompleteEol" title="->|"> <div className="mx_RoomStatusBar_tabCompleteEol" title="->|">
<TintableSvg src="img/eol.svg" width="22" height="16"/> <TintableSvg src="img/eol.svg" width="22" height="16"/>
Auto-complete Auto-complete
@ -233,7 +233,7 @@ module.exports = React.createClass({
<a className="mx_RoomStatusBar_resend_link" <a className="mx_RoomStatusBar_resend_link"
onClick={ this.props.onResendAllClick }> onClick={ this.props.onResendAllClick }>
Resend all Resend all
</a> or <a </a> or <a
className="mx_RoomStatusBar_resend_link" className="mx_RoomStatusBar_resend_link"
onClick={ this.props.onCancelAllClick }> onClick={ this.props.onCancelAllClick }>
cancel all cancel all
@ -247,7 +247,7 @@ module.exports = React.createClass({
// unread count trumps who is typing since the unread count is only // unread count trumps who is typing since the unread count is only
// set when you've scrolled up // set when you've scrolled up
if (this.props.numUnreadMessages) { if (this.props.numUnreadMessages) {
var unreadMsgs = this.props.numUnreadMessages + " new message" + var unreadMsgs = this.props.numUnreadMessages + " new message" +
(this.props.numUnreadMessages > 1 ? "s" : ""); (this.props.numUnreadMessages > 1 ? "s" : "");
return ( return (
@ -291,5 +291,5 @@ module.exports = React.createClass({
{content} {content}
</div> </div>
); );
}, },
}); });

View File

@ -31,16 +31,15 @@ var Modal = require("../../Modal");
var sdk = require('../../index'); var sdk = require('../../index');
var CallHandler = require('../../CallHandler'); var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete"); var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
var CommandEntry = require("../../TabCompleteEntries").CommandEntry;
var Resend = require("../../Resend"); var Resend = require("../../Resend");
var SlashCommands = require("../../SlashCommands");
var dis = require("../../dispatcher"); var dis = require("../../dispatcher");
var Tinter = require("../../Tinter"); var Tinter = require("../../Tinter");
var rate_limited_func = require('../../ratelimitedfunc'); var rate_limited_func = require('../../ratelimitedfunc');
var ObjectUtils = require('../../ObjectUtils'); var ObjectUtils = require('../../ObjectUtils');
var MatrixTools = require('../../MatrixTools'); var MatrixTools = require('../../MatrixTools');
import UserProvider from '../../autocomplete/UserProvider';
var DEBUG = false; var DEBUG = false;
if (DEBUG) { if (DEBUG) {
@ -117,6 +116,11 @@ module.exports = React.createClass({
guestsCanJoin: false, guestsCanJoin: false,
canPeek: false, canPeek: false,
// error object, as from the matrix client/server API
// If we failed to load information about the room,
// store the error here.
roomLoadError: null,
// this is true if we are fully scrolled-down, and are looking at // this is true if we are fully scrolled-down, and are looking at
// the end of the live timeline. It has the effect of hiding the // the end of the live timeline. It has the effect of hiding the
// 'scroll to bottom' knob, among a couple of other things. // 'scroll to bottom' knob, among a couple of other things.
@ -134,6 +138,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().on("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("accountData", this.onAccountData);
this.tabComplete = new TabComplete({ this.tabComplete = new TabComplete({
allowLooping: false, allowLooping: false,
@ -159,10 +164,11 @@ module.exports = React.createClass({
roomId: result.room_id, roomId: result.room_id,
roomLoading: !room, roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room), hasUnsentMessages: this._hasUnsentMessages(room),
}, this._updatePeeking); }, this._onHaveRoom);
}, (err) => { }, (err) => {
this.setState({ this.setState({
roomLoading: false, roomLoading: false,
roomLoadError: err,
}); });
}); });
} else { } else {
@ -172,11 +178,11 @@ module.exports = React.createClass({
room: room, room: room,
roomLoading: !room, roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room), hasUnsentMessages: this._hasUnsentMessages(room),
}, this._updatePeeking); }, this._onHaveRoom);
} }
}, },
_updatePeeking: function() { _onHaveRoom: function() {
// if this is an unknown room then we're in one of three states: // if this is an unknown room then we're in one of three states:
// - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can peek into (search engine) (we can /peek)
// - This is a room we can publicly join or were invited to. (we can /join) // - This is a room we can publicly join or were invited to. (we can /join)
@ -187,29 +193,47 @@ module.exports = React.createClass({
// Note that peeking works by room ID and room ID only, as opposed to joining // Note that peeking works by room ID and room ID only, as opposed to joining
// which must be by alias or invite wherever possible (peeking currently does // which must be by alias or invite wherever possible (peeking currently does
// not work over federation). // not work over federation).
if (!this.state.room && this.state.roomId) {
console.log("Attempting to peek into room %s", this.state.roomId);
MatrixClientPeg.get().peekInRoom(this.state.roomId).then((room) => { // NB. We peek if we are not in the room, although if we try to peek into
this.setState({ // a room in which we have a member event (ie. we've left) synapse will just
room: room, // send us the same data as we get in the sync (ie. the last events we saw).
roomLoading: false, var user_is_in_room = null;
}); if (this.state.room) {
this._onRoomLoaded(room); user_is_in_room = this.state.room.hasMembershipState(
}, (err) => { MatrixClientPeg.get().credentials.userId, 'join'
// This won't necessarily be a MatrixError, but we duck-type );
// here and say if it's got an 'errcode' key with the right value,
// it means we can't peek. this._updateAutoComplete();
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") { this.tabComplete.loadEntries(this.state.room);
// This is fine: the room just isn't peekable (we assume). }
if (!user_is_in_room && this.state.roomId) {
if (this.props.autoJoin) {
this.onJoinButtonClicked();
} else if (this.state.roomId) {
console.log("Attempting to peek into room %s", this.state.roomId);
MatrixClientPeg.get().peekInRoom(this.state.roomId).then((room) => {
this.setState({ this.setState({
room: room,
roomLoading: false, roomLoading: false,
}); });
} else { this._onRoomLoaded(room);
throw err; }, (err) => {
} // This won't necessarily be a MatrixError, but we duck-type
}).done(); // here and say if it's got an 'errcode' key with the right value,
} else if (this.state.room) { // it means we can't peek.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
// This is fine: the room just isn't peekable (we assume).
this.setState({
roomLoading: false,
});
} else {
throw err;
}
}).done();
}
} else if (user_is_in_room) {
MatrixClientPeg.get().stopPeeking(); MatrixClientPeg.get().stopPeeking();
this._onRoomLoaded(this.state.room); this._onRoomLoaded(this.state.room);
} }
@ -244,6 +268,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData); MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
} }
window.removeEventListener('resize', this.onResize); window.removeEventListener('resize', this.onResize);
@ -315,6 +340,10 @@ module.exports = React.createClass({
// ignore events for other rooms // ignore events for other rooms
if (!this.state.room || room.roomId != this.state.room.roomId) return; if (!this.state.room || room.roomId != this.state.room.roomId) return;
if (ev.getType() === "org.matrix.room.preview_urls") {
this._updatePreviewUrlVisibility(room);
}
// ignore anything but real-time updates at the end of the room: // ignore anything but real-time updates at the end of the room:
// updates from pagination will happen when the paginate completes. // updates from pagination will happen when the paginate completes.
if (toStartOfTimeline || !data || !data.liveEvent) return; if (toStartOfTimeline || !data || !data.liveEvent) return;
@ -334,12 +363,21 @@ module.exports = React.createClass({
}); });
} }
} }
// update the tab complete list as it depends on who most recently spoke,
// and that has probably just changed
if (ev.sender) {
this.tabComplete.onMemberSpoke(ev.sender);
// nb. we don't need to update the new autocomplete here since
// its results are currently ordered purely by search score.
}
}, },
// called when state.room is first initialised (either at initial load, // called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room). // after a successful peek, or after we join the room).
_onRoomLoaded: function(room) { _onRoomLoaded: function(room) {
this._calculatePeekRules(room); this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(room);
}, },
_calculatePeekRules: function(room) { _calculatePeekRules: function(room) {
@ -358,6 +396,42 @@ module.exports = React.createClass({
} }
}, },
_updatePreviewUrlVisibility: function(room) {
// console.log("_updatePreviewUrlVisibility");
// check our per-room overrides
var roomPreviewUrls = room.getAccountData("org.matrix.room.preview_urls");
if (roomPreviewUrls && roomPreviewUrls.getContent().disable !== undefined) {
this.setState({
showUrlPreview: !roomPreviewUrls.getContent().disable
});
return;
}
// check our global disable override
var userRoomPreviewUrls = MatrixClientPeg.get().getAccountData("org.matrix.preview_urls");
if (userRoomPreviewUrls && userRoomPreviewUrls.getContent().disable) {
this.setState({
showUrlPreview: false
});
return;
}
// check the room state event
var roomStatePreviewUrls = room.currentState.getStateEvents('org.matrix.room.preview_urls', '');
if (roomStatePreviewUrls && roomStatePreviewUrls.getContent().disable) {
this.setState({
showUrlPreview: false
});
return;
}
// otherwise, we assume they're on.
this.setState({
showUrlPreview: true
});
},
onRoom: function(room) { onRoom: function(room) {
// This event is fired when the room is 'stored' by the JS SDK, which // This event is fired when the room is 'stored' by the JS SDK, which
// means it's now a fully-fledged room object ready to be used, so // means it's now a fully-fledged room object ready to be used, so
@ -388,14 +462,23 @@ module.exports = React.createClass({
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}, },
onRoomAccountData: function(room, event) { onAccountData: function(event) {
if (room.roomId == this.props.roomId) { if (event.getType() === "org.matrix.preview_urls" && this.state.room) {
if (event.getType === "org.matrix.room.color_scheme") { this._updatePreviewUrlVisibility(this.state.room);
}
},
onRoomAccountData: function(event, room) {
if (room.roomId == this.state.roomId) {
if (event.getType() === "org.matrix.room.color_scheme") {
var color_scheme = event.getContent(); var color_scheme = event.getContent();
// XXX: we should validate the event // XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData"); console.log("Tinter.tint from onRoomAccountData");
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
} }
else if (event.getType() === "org.matrix.room.preview_urls") {
this._updatePreviewUrlVisibility(room);
}
} }
}, },
@ -410,8 +493,20 @@ module.exports = React.createClass({
return; return;
} }
if (this.props.ConferenceHandler &&
member.userId === this.props.ConferenceHandler.getConferenceUserIdForRoom(member.roomId)) {
this._updateConfCallNotification();
}
this._updateRoomMembers();
},
// rate limited because a power level change will emit an event for every
// member in the room.
_updateRoomMembers: new rate_limited_func(function() {
// a member state changed in this room, refresh the tab complete list // a member state changed in this room, refresh the tab complete list
this._updateTabCompleteList(); this.tabComplete.loadEntries(this.state.room);
this._updateAutoComplete();
// if we are now a member of the room, where we were not before, that // if we are now a member of the room, where we were not before, that
// means we have finished joining a room we were previously peeking // means we have finished joining a room we were previously peeking
@ -422,12 +517,7 @@ module.exports = React.createClass({
joining: false joining: false
}); });
} }
}, 500),
if (this.props.ConferenceHandler &&
member.userId === this.props.ConferenceHandler.getConferenceUserIdForRoom(member.roomId)) {
this._updateConfCallNotification();
}
},
_hasUnsentMessages: function(room) { _hasUnsentMessages: function(room) {
return this._getUnsentMessages(room).length > 0; return this._getUnsentMessages(room).length > 0;
@ -476,8 +566,6 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize); window.addEventListener('resize', this.onResize);
this.onResize(); this.onResize();
this._updateTabCompleteList();
// XXX: EVIL HACK to autofocus inviting on empty rooms. // XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer. // We use the setTimeout to avoid racing with focus_composer.
if (this.state.room && if (this.state.room &&
@ -495,22 +583,6 @@ module.exports = React.createClass({
} }
}, },
_updateTabCompleteList: new rate_limited_func(function() {
var cli = MatrixClientPeg.get();
if (!this.state.room || !this.tabComplete) {
return;
}
var members = this.state.room.getJoinedMembers().filter(function(member) {
if (member.userId !== cli.credentials.userId) return true;
});
this.tabComplete.setCompletionList(
MemberEntry.fromMemberList(members).concat(
CommandEntry.fromCommands(SlashCommands.getCommandList())
)
);
}, 500),
componentDidUpdate: function() { componentDidUpdate: function() {
if (this.refs.roomView) { if (this.refs.roomView) {
var roomView = ReactDOM.findDOMNode(this.refs.roomView); var roomView = ReactDOM.findDOMNode(this.refs.roomView);
@ -992,7 +1064,7 @@ module.exports = React.createClass({
this.setState({ this.setState({
rejecting: true rejecting: true
}); });
MatrixClientPeg.get().leave(this.props.roomAddress).done(function() { MatrixClientPeg.get().leave(this.state.roomId).done(function() {
dis.dispatch({ action: 'view_next_room' }); dis.dispatch({ action: 'view_next_room' });
self.setState({ self.setState({
rejecting: false rejecting: false
@ -1235,6 +1307,14 @@ module.exports = React.createClass({
} }
}, },
_updateAutoComplete: function() {
const myUserId = MatrixClientPeg.get().credentials.userId;
const members = this.state.room.getJoinedMembers().filter(function(member) {
if (member.userId !== myUserId) return true;
});
UserProvider.getInstance().setUserList(members);
},
render: function() { render: function() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader'); var RoomHeader = sdk.getComponent('rooms.RoomHeader');
var MessageComposer = sdk.getComponent('rooms.MessageComposer'); var MessageComposer = sdk.getComponent('rooms.MessageComposer');
@ -1267,6 +1347,7 @@ module.exports = React.createClass({
// We have no room object for this room, only the ID. // We have no room object for this room, only the ID.
// We've got to this room by following a link, possibly a third party invite. // We've got to this room by following a link, possibly a third party invite.
var room_alias = this.props.roomAddress[0] == '#' ? this.props.roomAddress : null;
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
<RoomHeader ref="header" <RoomHeader ref="header"
@ -1277,7 +1358,8 @@ module.exports = React.createClass({
<div className="mx_RoomView_auxPanel"> <div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={ this.onJoinButtonClicked } <RoomPreviewBar onJoinClick={ this.onJoinButtonClicked }
onRejectClick={ this.onRejectThreepidInviteButtonClicked } onRejectClick={ this.onRejectThreepidInviteButtonClicked }
canJoin={ true } canPreview={ false } canPreview={ false } error={ this.state.roomLoadError }
roomAlias={room_alias}
spinner={this.state.joining} spinner={this.state.joining}
inviterName={inviterName} inviterName={inviterName}
invitedEmail={invitedEmail} invitedEmail={invitedEmail}
@ -1315,7 +1397,7 @@ module.exports = React.createClass({
<RoomPreviewBar onJoinClick={ this.onJoinButtonClicked } <RoomPreviewBar onJoinClick={ this.onJoinButtonClicked }
onRejectClick={ this.onRejectButtonClicked } onRejectClick={ this.onRejectButtonClicked }
inviterName={ inviterName } inviterName={ inviterName }
canJoin={ true } canPreview={ false } canPreview={ false }
spinner={this.state.joining} spinner={this.state.joining}
room={this.state.room} room={this.state.room}
/> />
@ -1346,12 +1428,10 @@ module.exports = React.createClass({
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');
var tabEntries = this.tabComplete.isTabCompleting() ?
this.tabComplete.peek(6) : null;
statusBar = <RoomStatusBar statusBar = <RoomStatusBar
room={this.state.room} room={this.state.room}
tabCompleteEntries={tabEntries} tabComplete={this.tabComplete}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
hasUnsentMessages={this.state.hasUnsentMessages} hasUnsentMessages={this.state.hasUnsentMessages}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline} atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
@ -1385,7 +1465,7 @@ module.exports = React.createClass({
invitedEmail = this.props.thirdPartyInvite.invitedEmail; invitedEmail = this.props.thirdPartyInvite.invitedEmail;
} }
aux = ( aux = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked} canJoin={true} <RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onRejectClick={this.onRejectThreepidInviteButtonClicked} onRejectClick={this.onRejectThreepidInviteButtonClicked}
spinner={this.state.joining} spinner={this.state.joining}
inviterName={inviterName} inviterName={inviterName}
@ -1484,6 +1564,8 @@ module.exports = React.createClass({
hideMessagePanel = true; hideMessagePanel = true;
} }
// console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
var messagePanel = ( var messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef} <TimelinePanel ref={this._gatherTimelinePanelRef}
room={this.state.room} room={this.state.room}
@ -1493,6 +1575,7 @@ module.exports = React.createClass({
eventPixelOffset={this.props.eventPixelOffset} eventPixelOffset={this.props.eventPixelOffset}
onScroll={ this.onMessageListScroll } onScroll={ this.onMessageListScroll }
onReadMarkerUpdated={ this._updateTopUnreadMessagesBar } onReadMarkerUpdated={ this._updateTopUnreadMessagesBar }
showUrlPreview = { this.state.showUrlPreview }
opacity={ this.props.opacity } opacity={ this.props.opacity }
/>); />);

View File

@ -540,7 +540,6 @@ module.exports = React.createClass({
// it's not obvious why we have a separate div and ol anyway. // it's not obvious why we have a separate div and ol anyway.
return (<GeminiScrollbar autoshow={true} ref="geminiPanel" return (<GeminiScrollbar autoshow={true} ref="geminiPanel"
onScroll={this.onScroll} onResize={this.onResize} onScroll={this.onScroll} onResize={this.onResize}
relayoutOnUpdate={false}
className={this.props.className} style={this.props.style}> className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
<ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite"> <ol ref="itemlist" className="mx_RoomView_MessageList" aria-live="polite">

View File

@ -71,6 +71,9 @@ var TimelinePanel = React.createClass({
// half way down the viewport. // half way down the viewport.
eventPixelOffset: React.PropTypes.number, eventPixelOffset: React.PropTypes.number,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func, onScroll: React.PropTypes.func,
@ -934,6 +937,7 @@ var TimelinePanel = React.createClass({
readMarkerEventId={ this.state.readMarkerEventId } readMarkerEventId={ this.state.readMarkerEventId }
readMarkerVisible={ this.state.readMarkerVisible } readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate } suppressFirstDateSeparator={ this.state.canBackPaginate }
showUrlPreview = { this.props.showUrlPreview }
ourUserId={ MatrixClientPeg.get().credentials.userId } ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom } stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll } onScroll={ this.onMessageListScroll }

View File

@ -214,9 +214,10 @@ module.exports = React.createClass({
onFinished: this.onEmailDialogFinished, onFinished: this.onEmailDialogFinished,
}); });
}, (err) => { }, (err) => {
this.setState({email_add_pending: false});
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: "Unable to add email address", title: "Unable to add email address",
description: err.toString() description: err.message
}); });
}); });
ReactDOM.findDOMNode(this.refs.add_threepid_input).blur(); ReactDOM.findDOMNode(this.refs.add_threepid_input).blur();
@ -261,7 +262,64 @@ module.exports = React.createClass({
}); });
}, },
_renderDeviceInfo: function() { _renderUserInterfaceSettings: function() {
var client = MatrixClientPeg.get();
var settingsLabels = [
/*
{
id: 'alwaysShowTimestamps',
label: 'Always show message timestamps',
},
{
id: 'showTwelveHourTimestamps',
label: 'Show timestamps in 12 hour format (e.g. 2:30pm)',
},
{
id: 'useCompactLayout',
label: 'Use compact timeline layout',
},
{
id: 'useFixedWidthFont',
label: 'Use fixed width font',
},
*/
];
var syncedSettings = UserSettingsStore.getSyncedSettings();
return (
<div>
<h3>User Interface</h3>
<div className="mx_UserSettings_section">
<div className="mx_UserSettings_toggle">
<input id="urlPreviewsDisabled"
type="checkbox"
defaultChecked={ UserSettingsStore.getUrlPreviewsDisabled() }
onChange={ e => UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) }
/>
<label htmlFor="urlPreviewsDisabled">
Disable inline URL previews by default
</label>
</div>
</div>
{ settingsLabels.forEach( setting => {
<div className="mx_UserSettings_toggle">
<input id={ setting.id }
type="checkbox"
defaultChecked={ syncedSettings[setting.id] }
onChange={ e => UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) }
/>
<label htmlFor={ setting.id }>
{ settings.label }
</label>
</div>
})}
</div>
);
},
_renderCryptoInfo: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) { if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null; return null;
} }
@ -282,6 +340,45 @@ module.exports = React.createClass({
); );
}, },
_renderDevicesPanel: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var DevicesPanel = sdk.getComponent('settings.DevicesPanel');
return (
<div>
<h3>Devices</h3>
<DevicesPanel className="mx_UserSettings_section" />
</div>
);
},
_renderLabs: function () {
let features = LABS_FEATURES.map(feature => (
<div key={feature.id} className="mx_UserSettings_toggle">
<input
type="checkbox"
id={feature.id}
name={feature.id}
defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)}
onChange={e => {
UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked);
this.forceUpdate();
}}/>
<label htmlFor={feature.id}>{feature.name}</label>
</div>
));
return (
<div>
<h3>Labs</h3>
<div className="mx_UserSettings_section">
<p>These are experimental features that may break in unexpected ways. Use with caution.</p>
{features}
</div>
</div>
)
},
render: function() { render: function() {
var self = this; var self = this;
var Loader = sdk.getComponent("elements.Spinner"); var Loader = sdk.getComponent("elements.Spinner");
@ -302,6 +399,7 @@ module.exports = React.createClass({
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var Notifications = sdk.getComponent("settings.Notifications"); var Notifications = sdk.getComponent("settings.Notifications");
var EditableText = sdk.getComponent('elements.EditableText'); var EditableText = sdk.getComponent('elements.EditableText');
var avatarUrl = ( var avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
); );
@ -376,36 +474,11 @@ module.exports = React.createClass({
</div>); </div>);
} }
this._renderLabs = function () {
let features = LABS_FEATURES.map(feature => (
<div key={feature.id}>
<input
type="checkbox"
id={feature.id}
name={feature.id}
defaultChecked={UserSettingsStore.isFeatureEnabled(feature.id)}
onChange={e => UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} />
<label htmlFor={feature.id}>{feature.name}</label>
</div>
));
return (
<div>
<h3>Labs</h3>
<div className="mx_UserSettings_section">
<p>These are experimental features that may break in unexpected ways. Use with caution.</p>
{features}
</div>
</div>
)
};
return ( return (
<div className="mx_UserSettings"> <div className="mx_UserSettings">
<SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/> <SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/>
<GeminiScrollbar className="mx_UserSettings_body" <GeminiScrollbar className="mx_UserSettings_body"
relayoutOnUpdate={false}
autoshow={true}> autoshow={true}>
<h3>Profile</h3> <h3>Profile</h3>
@ -452,9 +525,10 @@ module.exports = React.createClass({
{notification_area} {notification_area}
{this._renderDeviceInfo()} {this._renderUserInterfaceSettings()}
{this._renderLabs()} {this._renderLabs()}
{this._renderDevicesPanel()}
{this._renderCryptoInfo()}
<h3>Advanced</h3> <h3>Advanced</h3>

View File

@ -232,7 +232,9 @@ module.exports = React.createClass({displayName: 'Login',
<div className="mx_Login_box"> <div className="mx_Login_box">
<LoginHeader /> <LoginHeader />
<div> <div>
<h2>Sign in</h2> <h2>Sign in
{ loader }
</h2>
{ this.componentForStep(this._getCurrentFlowStep()) } { this.componentForStep(this._getCurrentFlowStep()) }
<ServerConfig ref="serverConfig" <ServerConfig ref="serverConfig"
withToggleButton={true} withToggleButton={true}
@ -244,7 +246,6 @@ module.exports = React.createClass({displayName: 'Login',
onIsUrlChanged={this.onIsUrlChanged} onIsUrlChanged={this.onIsUrlChanged}
delayTimeMs={1000}/> delayTimeMs={1000}/>
<div className="mx_Login_error"> <div className="mx_Login_error">
{ loader }
{ this.state.errorText } { this.state.errorText }
</div> </div>
<a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#"> <a className="mx_Login_create" onClick={this.props.onRegisterClick} href="#">

View File

@ -54,6 +54,16 @@ module.exports = React.createClass({
return { return {
busy: false, busy: false,
errorText: null, errorText: null,
// We remember the values entered by the user because
// the registration form will be unmounted during the
// course of registration, but if there's an error we
// want to bring back the registration form with the
// values the user entered still in it. We can keep
// them in this component's state since this component
// persist for the duration of the registration process.
formVals: {
email: this.props.email,
},
}; };
}, },
@ -108,7 +118,8 @@ module.exports = React.createClass({
var self = this; var self = this;
this.setState({ this.setState({
errorText: "", errorText: "",
busy: true busy: true,
formVals: formVals,
}); });
if (formVals.username !== this.props.username) { if (formVals.username !== this.props.username) {
@ -228,11 +239,15 @@ module.exports = React.createClass({
break; // NOP break; // NOP
case "Register.START": case "Register.START":
case "Register.STEP_m.login.dummy": case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
registerStep = ( registerStep = (
<RegistrationForm <RegistrationForm
showEmail={true} showEmail={true}
defaultUsername={this.props.username} defaultUsername={this.state.formVals.username}
defaultEmail={this.props.email} defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password}
guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH} minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed} onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} /> onRegisterClick={this.onFormSubmit} />

View File

@ -18,6 +18,7 @@ limitations under the License.
var React = require('react'); var React = require('react');
var AvatarLogic = require("../../../Avatar"); var AvatarLogic = require("../../../Avatar");
import {emojifyText} from '../../../HtmlUtils';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'BaseAvatar', displayName: 'BaseAvatar',
@ -132,32 +133,36 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var name = this.props.name;
var imageUrl = this.state.imageUrls[this.state.urlsIndex]; var imageUrl = this.state.imageUrls[this.state.urlsIndex];
const {
name, idName, title, url, urls, width, height, resizeMethod,
defaultToInitialLetter,
...otherProps
} = this.props;
if (imageUrl === this.state.defaultImageUrl) { if (imageUrl === this.state.defaultImageUrl) {
var initialLetter = this._getInitialLetter(this.props.name); var initialLetter = emojifyText(this._getInitialLetter(name));
return ( return (
<span className="mx_BaseAvatar" {...this.props}> <span className="mx_BaseAvatar" {...otherProps}>
<span className="mx_BaseAvatar_initial" aria-hidden="true" <span className="mx_BaseAvatar_initial" aria-hidden="true"
style={{ fontSize: (this.props.width * 0.65) + "px", style={{ fontSize: (width * 0.65) + "px",
width: this.props.width + "px", width: width + "px",
lineHeight: this.props.height + "px" }}> lineHeight: height + "px" }}
{ initialLetter } dangerouslySetInnerHTML={initialLetter}>
</span> </span>
<img className="mx_BaseAvatar_image" src={imageUrl} <img className="mx_BaseAvatar_image" src={imageUrl}
alt="" title={this.props.title} onError={this.onError} alt="" title={title} onError={this.onError}
width={this.props.width} height={this.props.height} /> width={width} height={height} />
</span> </span>
); );
} }
return ( return (
<img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl} <img className="mx_BaseAvatar mx_BaseAvatar_image" src={imageUrl}
onError={this.onError} onError={this.onError}
width={this.props.width} height={this.props.height} width={width} height={height}
title={this.props.title} alt="" title={title} alt=""
{...this.props} /> {...otherProps} />
); );
} }
}); });

View File

@ -59,9 +59,12 @@ module.exports = React.createClass({
render: function() { render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var {member, ...otherProps} = this.props;
return ( return (
<BaseAvatar {...this.props} name={this.state.name} title={this.state.title} <BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={this.props.member.userId} url={this.state.imageUrl} /> idName={member.userId} url={this.state.imageUrl} />
); );
} }
}); });

View File

@ -126,11 +126,13 @@ module.exports = React.createClass({
render: function() { render: function() {
var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); var BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
var roomName = this.props.room ? this.props.room.name : this.props.oobData.name; var {room, oobData, ...otherProps} = this.props;
var roomName = room ? room.name : oobData.name;
return ( return (
<BaseAvatar {...this.props} name={roomName} <BaseAvatar {...otherProps} name={roomName}
idName={this.props.room ? this.props.room.roomId : null} idName={room ? room.roomId : null}
urls={this.state.urls} /> urls={this.state.urls} />
); );
} }

View File

@ -59,7 +59,7 @@ module.exports = React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={this.props.focus}> <button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
{this.props.button} {this.props.button}
</button> </button>
</div> </div>

View File

@ -46,7 +46,7 @@ module.exports = React.createClass({
Sign out? Sign out?
</div> </div>
<div className="mx_Dialog_buttons" onKeyDown={ this.onKeyDown }> <div className="mx_Dialog_buttons" onKeyDown={ this.onKeyDown }>
<button autoFocus onClick={this.logOut}>Sign Out</button> <button className="mx_Dialog_primary" autoFocus onClick={this.logOut}>Sign Out</button>
<button onClick={this.cancelPrompt}>Cancel</button> <button onClick={this.cancelPrompt}>Cancel</button>
</div> </div>
</div> </div>

View File

@ -63,7 +63,7 @@ module.exports = React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.props.onFinished} autoFocus={true}> <button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={true}>
Cancel Cancel
</button> </button>
<button onClick={this.onRegisterClicked}> <button onClick={this.onRegisterClicked}>

View File

@ -56,7 +56,7 @@ module.exports = React.createClass({
{this.props.description} {this.props.description}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onOk} autoFocus={this.props.focus}> <button className="mx_Dialog_primary" onClick={this.onOk} autoFocus={this.props.focus}>
{this.props.button} {this.props.button}
</button> </button>

View File

@ -76,7 +76,7 @@ module.exports = React.createClass({
/> />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<input type="submit" value="Set" /> <input className="mx_Dialog_primary" type="submit" value="Set" />
</div> </div>
</form> </form>
</div> </div>

View File

@ -86,7 +86,7 @@ module.exports = React.createClass({
<button onClick={this.onCancel}> <button onClick={this.onCancel}>
Cancel Cancel
</button> </button>
<button onClick={this.onOk}> <button className="mx_Dialog_primary" onClick={this.onOk}>
{this.props.button} {this.props.button}
</button> </button>
</div> </div>

View File

@ -49,6 +49,8 @@ module.exports = React.createClass({
label: '', label: '',
placeholder: '', placeholder: '',
editable: true, editable: true,
className: "mx_EditableText",
placeholderClassName: "mx_EditableText_placeholder",
}; };
}, },
@ -92,7 +94,7 @@ module.exports = React.createClass({
this.refs.editable_div.textContent = this.value; this.refs.editable_div.textContent = this.value;
this.refs.editable_div.setAttribute("class", this.props.className); this.refs.editable_div.setAttribute("class", this.props.className);
this.placeholder = false; this.placeholder = false;
} }
}, },
getValue: function() { getValue: function() {
@ -101,7 +103,7 @@ module.exports = React.createClass({
setValue: function(value) { setValue: function(value) {
this.value = value; this.value = value;
this.showPlaceholder(!this.value); this.showPlaceholder(!this.value);
}, },
edit: function() { edit: function() {
@ -125,7 +127,7 @@ module.exports = React.createClass({
onKeyDown: function(ev) { onKeyDown: function(ev) {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (this.placeholder) { if (this.placeholder) {
this.showPlaceholder(false); this.showPlaceholder(false);
} }
@ -173,7 +175,7 @@ module.exports = React.createClass({
var range = document.createRange(); var range = document.createRange();
range.setStart(node, 0); range.setStart(node, 0);
range.setEnd(node, node.length); range.setEnd(node, node.length);
var sel = window.getSelection(); var sel = window.getSelection();
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);

View File

@ -0,0 +1,147 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import sdk from '../../../index';
import q from 'q';
/**
* A component which wraps an EditableText, with a spinner while updates take
* place.
*
* Parent components should supply an 'onSubmit' callback which returns a
* promise; a spinner is shown until the promise resolves.
*
* The parent can also supply a 'getIntialValue' callback, which works in a
* similarly asynchronous way. If this is not provided, the initial value is
* taken from the 'initialValue' property.
*/
export default class EditableTextContainer extends React.Component {
constructor(props, context) {
super(props, context);
this._unmounted = false;
this.state = {
busy: false,
errorString: null,
value: props.initialValue,
};
this._onValueChanged = this._onValueChanged.bind(this);
}
componentWillMount() {
if (this.props.getInitialValue === undefined) {
// use whatever was given in the initialValue property.
return;
}
this.setState({busy: true});
this.props.getInitialValue().done(
(result) => {
if (this._unmounted) { return; }
this.setState({
busy: false,
value: result,
});
},
(error) => {
if (this._unmounted) { return; }
this.setState({
errorString: error.toString(),
busy: false,
});
}
);
}
componentWillUnmount() {
this._unmounted = true;
}
_onValueChanged(value, shouldSubmit) {
if (!shouldSubmit) {
return;
}
this.setState({
busy: true,
errorString: null,
});
this.props.onSubmit(value).done(
() => {
if (this._unmounted) { return; }
this.setState({
busy: false,
value: value,
});
},
(error) => {
if (this._unmounted) { return; }
this.setState({
errorString: error.toString(),
busy: false,
});
}
);
}
render() {
if (this.state.busy) {
var Loader = sdk.getComponent("elements.Spinner");
return (
<Loader />
);
} else if (this.state.errorString) {
return (
<div className="error">{this.state.errorString}</div>
);
} else {
var EditableText = sdk.getComponent('elements.EditableText');
return (
<EditableText initialValue={this.state.value}
placeholder={this.props.placeholder}
onValueChanged={this._onValueChanged}
/>
);
}
}
}
EditableTextContainer.propTypes = {
/* callback to retrieve the initial value. */
getInitialValue: React.PropTypes.func,
/* initial value; used if getInitialValue is not given */
initialValue: React.PropTypes.string,
/* placeholder text to use when the value is empty (and not being
* edited) */
placeholder: React.PropTypes.string,
/* callback to update the value. Called with a single argument: the new
* value. */
onSubmit: React.PropTypes.func,
};
EditableTextContainer.defaultProps = {
initialValue: "",
placeholder: "",
onSubmit: function(v) {return q(); },
};

View File

@ -34,10 +34,15 @@ module.exports = React.createClass({
propTypes: { propTypes: {
value: React.PropTypes.number.isRequired, value: React.PropTypes.number.isRequired,
// if true, the <select/> should be a 'controlled' form element and updated by React // if true, the <select/> should be a 'controlled' form element and updated by React
// to reflect the current value, rather than left freeform. // to reflect the current value, rather than left freeform.
// MemberInfo uses controlled; RoomSettings uses non-controlled. // MemberInfo uses controlled; RoomSettings uses non-controlled.
controlled: React.PropTypes.bool.isRequired, //
// ignored if disabled is truthy. false by default.
controlled: React.PropTypes.bool,
// should the user be able to change the value? false by default.
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
onChange: React.PropTypes.func, onChange: React.PropTypes.func,
}, },

View File

@ -35,8 +35,16 @@ module.exports = React.createClass({
displayName: 'RegistrationForm', displayName: 'RegistrationForm',
propTypes: { propTypes: {
// Values pre-filled in the input boxes when the component loads
defaultEmail: React.PropTypes.string, defaultEmail: React.PropTypes.string,
defaultUsername: React.PropTypes.string, defaultUsername: React.PropTypes.string,
defaultPassword: React.PropTypes.string,
// A username that will be used if no username is entered.
// Specifying this param will also warn the user that entering
// a different username will cause a fresh account to be generated.
guestUsername: React.PropTypes.string,
showEmail: React.PropTypes.bool, showEmail: React.PropTypes.bool,
minPasswordLength: React.PropTypes.number, minPasswordLength: React.PropTypes.number,
onError: React.PropTypes.func, onError: React.PropTypes.func,
@ -55,10 +63,6 @@ module.exports = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
email: this.props.defaultEmail,
username: null,
password: null,
passwordConfirm: null,
fieldValid: {} fieldValid: {}
}; };
}, },
@ -103,7 +107,7 @@ module.exports = React.createClass({
_doSubmit: function() { _doSubmit: function() {
var promise = this.props.onRegisterClick({ var promise = this.props.onRegisterClick({
username: this.refs.username.value.trim() || this.props.defaultUsername, 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: this.refs.email.value.trim()
}); });
@ -144,7 +148,7 @@ module.exports = React.createClass({
break; break;
case FIELD_USERNAME: case FIELD_USERNAME:
// XXX: SPEC-1 // XXX: SPEC-1
var username = this.refs.username.value.trim() || this.props.defaultUsername; var username = this.refs.username.value.trim() || this.props.guestUsername;
if (encodeURIComponent(username) != username) { if (encodeURIComponent(username) != username) {
this.markFieldValid( this.markFieldValid(
field_id, field_id,
@ -225,7 +229,7 @@ module.exports = React.createClass({
emailSection = ( emailSection = (
<input className="mx_Login_field" type="text" ref="email" <input className="mx_Login_field" type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)" autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.state.email} defaultValue={this.props.defaultEmail}
style={this._styleField(FIELD_EMAIL)} style={this._styleField(FIELD_EMAIL)}
onBlur={function() {self.validateField(FIELD_EMAIL)}} /> onBlur={function() {self.validateField(FIELD_EMAIL)}} />
); );
@ -237,8 +241,8 @@ module.exports = React.createClass({
} }
var placeholderUserName = "User name"; var placeholderUserName = "User name";
if (this.props.defaultUsername) { if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.defaultUsername + ")" placeholderUserName += " (default: " + this.props.guestUsername + ")"
} }
return ( return (
@ -247,23 +251,23 @@ module.exports = React.createClass({
{emailSection} {emailSection}
<br /> <br />
<input className="mx_Login_field" type="text" ref="username" <input className="mx_Login_field" type="text" ref="username"
placeholder={ placeholderUserName } defaultValue={this.state.username} placeholder={ placeholderUserName } defaultValue={this.props.defaultUsername}
style={this._styleField(FIELD_USERNAME)} style={this._styleField(FIELD_USERNAME)}
onBlur={function() {self.validateField(FIELD_USERNAME)}} /> onBlur={function() {self.validateField(FIELD_USERNAME)}} />
<br /> <br />
{ this.props.defaultUsername ? { 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 className="mx_Login_field" type="password" ref="password" <input className="mx_Login_field" type="password" ref="password"
style={this._styleField(FIELD_PASSWORD)} style={this._styleField(FIELD_PASSWORD)}
onBlur={function() {self.validateField(FIELD_PASSWORD)}} onBlur={function() {self.validateField(FIELD_PASSWORD)}}
placeholder="Password" defaultValue={this.state.password} /> placeholder="Password" defaultValue={this.props.defaultPassword} />
<br /> <br />
<input className="mx_Login_field" type="password" ref="passwordConfirm" <input className="mx_Login_field" type="password" ref="passwordConfirm"
placeholder="Confirm password" placeholder="Confirm password"
style={this._styleField(FIELD_PASSWORD_CONFIRM)} style={this._styleField(FIELD_PASSWORD_CONFIRM)}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}} onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
defaultValue={this.state.passwordConfirm} /> defaultValue={this.props.defaultPassword} />
<br /> <br />
{registerButton} {registerButton}
</form> </form>

View File

@ -34,7 +34,7 @@ module.exports = React.createClass({
} }
if (fullWidth < thumbWidth && fullHeight < thumbHeight) { if (fullWidth < thumbWidth && fullHeight < thumbHeight) {
// no scaling needs to be applied // no scaling needs to be applied
return fullHeight; return 1;
} }
var widthMulti = thumbWidth / fullWidth; var widthMulti = thumbWidth / fullWidth;
var heightMulti = thumbHeight / fullHeight; var heightMulti = thumbHeight / fullHeight;

View File

@ -38,6 +38,9 @@ module.exports = React.createClass({
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: React.PropTypes.string, highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* callback called when dynamic content in events are loaded */ /* callback called when dynamic content in events are loaded */
onWidgetLoad: React.PropTypes.func, onWidgetLoad: React.PropTypes.func,
}, },
@ -71,6 +74,7 @@ module.exports = React.createClass({
return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights} return <BodyType ref="body" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />; onWidgetLoad={this.props.onWidgetLoad} />;
}, },
}); });

View File

@ -39,6 +39,9 @@ module.exports = React.createClass({
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: React.PropTypes.string, highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* callback for when our widget has loaded */ /* callback for when our widget has loaded */
onWidgetLoad: React.PropTypes.func, onWidgetLoad: React.PropTypes.func,
}, },
@ -56,34 +59,47 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
linkifyElement(this.refs.content, linkifyMatrix.options); linkifyElement(this.refs.content, linkifyMatrix.options);
this.calculateUrlPreview();
var links = this.findLinks(this.refs.content.children);
if (links.length) {
this.setState({ links: links.map((link)=>{
return link.getAttribute("href");
})});
// lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) {
var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden });
}
}
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this)); HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
}, },
componentDidUpdate: function() {
this.calculateUrlPreview();
},
shouldComponentUpdate: function(nextProps, nextState) { shouldComponentUpdate: function(nextProps, nextState) {
//console.log("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
// exploit that events are immutable :) // exploit that events are immutable :)
// ...and that .links is only ever set in componentDidMount and never changes
return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
nextProps.highlights !== this.props.highlights || nextProps.highlights !== this.props.highlights ||
nextProps.highlightLink !== this.props.highlightLink || nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextState.links !== this.state.links || nextState.links !== this.state.links ||
nextState.widgetHidden !== this.state.widgetHidden); nextState.widgetHidden !== this.state.widgetHidden);
}, },
calculateUrlPreview: function() {
//console.log("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
if (this.props.showUrlPreview && !this.state.links.length) {
var links = this.findLinks(this.refs.content.children);
if (links.length) {
this.setState({ links: links.map((link)=>{
return link.getAttribute("href");
})});
// lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) {
var hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
this.setState({ widgetHidden: hidden });
}
}
}
},
findLinks: function(nodes) { findLinks: function(nodes) {
var links = []; var links = [];
for (var i = 0; i < nodes.length; i++) { for (var i = 0; i < nodes.length; i++) {
@ -163,12 +179,14 @@ module.exports = React.createClass({
render: function() { render: function() {
var mxEvent = this.props.mxEvent; var mxEvent = this.props.mxEvent;
var content = mxEvent.getContent(); var content = mxEvent.getContent();
var body = HtmlUtils.bodyToHtml(content, this.props.highlights, var body = HtmlUtils.bodyToHtml(content, this.props.highlights, {});
{highlightLink: this.props.highlightLink});
if (this.props.highlightLink) {
body = <a href={ this.props.highlightLink }>{ body }</a>;
}
var widgets; var widgets;
if (this.state.links.length && !this.state.widgetHidden) { if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget'); var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
widgets = this.state.links.map((link)=>{ widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget return <LinkPreviewWidget

View File

@ -19,6 +19,7 @@ limitations under the License.
var React = require('react'); var React = require('react');
var TextForEvent = require('../../../TextForEvent'); var TextForEvent = require('../../../TextForEvent');
import {emojifyText} from '../../../HtmlUtils';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'TextualEvent', displayName: 'TextualEvent',
@ -31,11 +32,11 @@ module.exports = React.createClass({
render: function() { render: function() {
var text = TextForEvent.textForEvent(this.props.mxEvent); var text = TextForEvent.textForEvent(this.props.mxEvent);
if (text == null || text.length == 0) return null; if (text == null || text.length === 0) return null;
let textHTML = emojifyText(TextForEvent.textForEvent(this.props.mxEvent));
return ( return (
<div className="mx_TextualEvent"> <div className="mx_TextualEvent" dangerouslySetInnerHTML={textHTML}>
{TextForEvent.textForEvent(this.props.mxEvent)}
</div> </div>
); );
}, },

View File

@ -83,13 +83,11 @@ module.exports = React.createClass({
alias: this.state.canonicalAlias alias: this.state.canonicalAlias
}, "" }, ""
) )
); );
} }
// save new aliases for m.room.aliases // save new aliases for m.room.aliases
var aliasOperations = this.getAliasOperations(); var aliasOperations = this.getAliasOperations();
var promises = [];
for (var i = 0; i < aliasOperations.length; i++) { for (var i = 0; i < aliasOperations.length; i++) {
var alias_operation = aliasOperations[i]; var alias_operation = aliasOperations[i];
console.log("alias %s %s", alias_operation.place, alias_operation.val); console.log("alias %s %s", alias_operation.place, alias_operation.val);
@ -301,7 +299,7 @@ module.exports = React.createClass({
<div className="mx_RoomSettings_addAlias"> <div className="mx_RoomSettings_addAlias">
<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>
</div> : "" </div> : ""
} }
</div> </div>

View File

@ -57,7 +57,7 @@ module.exports = React.createClass({
data.primary_color = scheme.primary_color; data.primary_color = scheme.primary_color;
data.secondary_color = scheme.secondary_color; data.secondary_color = scheme.secondary_color;
data.index = this._getColorIndex(data); data.index = this._getColorIndex(data);
if (data.index === -1) { if (data.index === -1) {
// append the unrecognised colours to our palette // append the unrecognised colours to our palette
data.index = ROOM_COLORS.length; data.index = ROOM_COLORS.length;

View File

@ -0,0 +1,157 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var q = require("q");
var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require("../../../index");
var Modal = require("../../../Modal");
var UserSettingsStore = require('../../../UserSettingsStore');
module.exports = React.createClass({
displayName: 'UrlPreviewSettings',
propTypes: {
room: React.PropTypes.object,
},
getInitialState: function() {
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
var roomPreviewUrls = this.props.room.currentState.getStateEvents('org.matrix.room.preview_urls', '');
var userPreviewUrls = this.props.room.getAccountData("org.matrix.room.preview_urls");
return {
globalDisableUrlPreview: (roomPreviewUrls && roomPreviewUrls.getContent().disable) || false,
userDisableUrlPreview: (userPreviewUrls && (userPreviewUrls.getContent().disable === true)) || false,
userEnableUrlPreview: (userPreviewUrls && (userPreviewUrls.getContent().disable === false)) || false,
};
},
componentDidMount: function() {
this.originalState = Object.assign({}, this.state);
},
saveSettings: function() {
var promises = [];
if (this.state.globalDisableUrlPreview !== this.originalState.globalDisableUrlPreview) {
console.log("UrlPreviewSettings: Updating room's preview_urls state event");
promises.push(
MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId, "org.matrix.room.preview_urls", {
disable: this.state.globalDisableUrlPreview
}, ""
)
);
}
var content = undefined;
if (this.state.userDisableUrlPreview !== this.originalState.userDisableUrlPreview) {
console.log("UrlPreviewSettings: Disabling user's per-room preview_urls");
content = this.state.userDisableUrlPreview ? { disable : true } : {};
}
if (this.state.userEnableUrlPreview !== this.originalState.userEnableUrlPreview) {
console.log("UrlPreviewSettings: Enabling user's per-room preview_urls");
if (!content || content.disable === undefined) {
content = this.state.userEnableUrlPreview ? { disable : false } : {};
}
}
if (content) {
promises.push(
MatrixClientPeg.get().setRoomAccountData(
this.props.room.roomId, "org.matrix.room.preview_urls", content
)
);
}
console.log("UrlPreviewSettings: saveSettings: " + JSON.stringify(promises));
return promises;
},
onGlobalDisableUrlPreviewChange: function() {
this.setState({
globalDisableUrlPreview: this.refs.globalDisableUrlPreview.checked ? true : false,
});
},
onUserEnableUrlPreviewChange: function() {
this.setState({
userDisableUrlPreview: false,
userEnableUrlPreview: this.refs.userEnableUrlPreview.checked ? true : false,
});
},
onUserDisableUrlPreviewChange: function() {
this.setState({
userDisableUrlPreview: this.refs.userDisableUrlPreview.checked ? true : false,
userEnableUrlPreview: false,
});
},
render: function() {
var self = this;
var roomState = this.props.room.currentState;
var cli = MatrixClientPeg.get();
var maySetRoomPreviewUrls = roomState.mayClientSendStateEvent('org.matrix.room.preview_urls', cli);
var disableRoomPreviewUrls;
if (maySetRoomPreviewUrls) {
disableRoomPreviewUrls =
<label>
<input type="checkbox" ref="globalDisableUrlPreview"
onChange={ this.onGlobalDisableUrlPreviewChange }
checked={ this.state.globalDisableUrlPreview } />
Disable URL previews by default for participants in this room
</label>
}
else {
disableRoomPreviewUrls =
<label>
URL previews are { this.state.globalDisableUrlPreview ? "disabled" : "enabled" } by default for participants in this room.
</label>
}
return (
<div className="mx_RoomSettings_toggles">
<h3>URL Previews</h3>
<label>
You have <a href="#/settings">{ UserSettingsStore.getUrlPreviewsDisabled() ? 'disabled' : 'enabled' }</a> URL previews by default.
</label>
{ disableRoomPreviewUrls }
<label>
<input type="checkbox" ref="userEnableUrlPreview"
onChange={ this.onUserEnableUrlPreviewChange }
checked={ this.state.userEnableUrlPreview } />
Enable URL previews for this room (affects only you)
</label>
<label>
<input type="checkbox" ref="userDisableUrlPreview"
onChange={ this.onUserDisableUrlPreviewChange }
checked={ this.state.userDisableUrlPreview } />
Disable URL previews for this room (affects only you)
</label>
</div>
);
}
});

View File

@ -0,0 +1,164 @@
import React from 'react';
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import {getCompletions} from '../../../autocomplete/Autocompleter';
export default class Autocomplete extends React.Component {
constructor(props) {
super(props);
this.onConfirm = this.onConfirm.bind(this);
this.state = {
// list of completionResults, each containing completions
completions: [],
// array of completions, so we can look up current selection by offset quickly
completionList: [],
// how far down the completion list we are
selectionOffset: 0,
};
}
componentWillReceiveProps(props, state) {
if (props.query === this.props.query) {
return;
}
getCompletions(props.query, props.selection).forEach(completionResult => {
try {
completionResult.completions.then(completions => {
let i = this.state.completions.findIndex(
completion => completion.provider === completionResult.provider
);
i = i === -1 ? this.state.completions.length : i;
let newCompletions = Object.assign([], this.state.completions);
completionResult.completions = completions;
newCompletions[i] = completionResult;
this.setState({
completions: newCompletions,
completionList: flatMap(newCompletions, provider => provider.completions),
});
}, err => {
console.error(err);
});
} catch (e) {
// An error in one provider shouldn't mess up the rest.
console.error(e);
}
});
}
countCompletions(): number {
return this.state.completions.map(completionResult => {
return completionResult.completions.length;
}).reduce((l, r) => l + r);
}
// called from MessageComposerInput
onUpArrow(): boolean {
let completionCount = this.countCompletions(),
selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount;
if (!completionCount) {
return false;
}
this.setSelection(selectionOffset);
return true;
}
// called from MessageComposerInput
onDownArrow(): boolean {
let completionCount = this.countCompletions(),
selectionOffset = (this.state.selectionOffset + 1) % completionCount;
if (!completionCount) {
return false;
}
this.setSelection(selectionOffset);
return true;
}
/** called from MessageComposerInput
* @returns {boolean} whether confirmation was handled
*/
onConfirm(): boolean {
if (this.countCompletions() === 0) {
return false;
}
let selectedCompletion = this.state.completionList[this.state.selectionOffset];
this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion);
return true;
}
setSelection(selectionOffset: number) {
this.setState({selectionOffset});
}
render() {
let position = 0;
let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => {
let className = classNames('mx_Autocomplete_Completion', {
'selected': position === this.state.selectionOffset,
});
let componentPosition = position;
position++;
let onMouseOver = () => this.setSelection(componentPosition);
let onClick = () => {
this.setSelection(componentPosition);
this.onConfirm();
};
return (
<div key={i}
className={className}
onMouseOver={onMouseOver}
onClick={onClick}>
{completion.component}
</div>
);
});
return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<span className="mx_Autocomplete_provider_name">{completionResult.provider.getName()}</span>
<ReactCSSTransitionGroup
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{completions}
</ReactCSSTransitionGroup>
</div>
) : null;
});
return (
<div className="mx_Autocomplete">
<ReactCSSTransitionGroup
component="div"
transitionName="autocomplete"
transitionEnterTimeout={300}
transitionLeaveTimeout={300}>
{renderedCompletions}
</ReactCSSTransitionGroup>
</div>
);
}
}
Autocomplete.propTypes = {
// the query string for which to show autocomplete suggestions
query: React.PropTypes.string.isRequired,
// method invoked with range and text content when completion is confirmed
onConfirm: React.PropTypes.func.isRequired,
};

View File

@ -20,6 +20,7 @@ var React = require('react');
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index'); var sdk = require('../../../index');
import {emojifyText} from '../../../HtmlUtils';
var PRESENCE_CLASS = { var PRESENCE_CLASS = {
@ -28,6 +29,23 @@ var PRESENCE_CLASS = {
"unavailable": "mx_EntityTile_unavailable" "unavailable": "mx_EntityTile_unavailable"
}; };
function presenceClassForMember(presenceState, lastActiveAgo) {
// offline is split into two categories depending on whether we have
// a last_active_ago for them.
if (presenceState == 'offline') {
if (lastActiveAgo) {
return PRESENCE_CLASS['offline'] + '_beenactive';
} else {
return PRESENCE_CLASS['offline'] + '_neveractive';
}
} else if (presenceState) {
return PRESENCE_CLASS[presenceState];
} else {
return PRESENCE_CLASS['offline'] + '_neveractive';
}
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'EntityTile', displayName: 'EntityTile',
@ -78,10 +96,14 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
var presenceClass = PRESENCE_CLASS[this.props.presenceState] || "mx_EntityTile_offline"; const presenceClass = presenceClassForMember(
this.props.presenceState, this.props.presenceLastActiveAgo
);
var mainClassName = "mx_EntityTile "; var mainClassName = "mx_EntityTile ";
mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : ""); mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : "");
var nameEl; var nameEl;
let nameHTML = emojifyText(this.props.name);
if (this.state.hover && !this.props.suppressOnHover) { if (this.state.hover && !this.props.suppressOnHover) {
var activeAgo = this.props.presenceLastActiveAgo ? var activeAgo = this.props.presenceLastActiveAgo ?
@ -92,7 +114,7 @@ module.exports = React.createClass({
nameEl = ( nameEl = (
<div className="mx_EntityTile_details"> <div className="mx_EntityTile_details">
<img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12"/> <img className="mx_EntityTile_chevron" src="img/member_chevron.png" width="8" height="12"/>
<div className="mx_EntityTile_name_hover">{ this.props.name }</div> <div className="mx_EntityTile_name_hover" dangerouslySetInnerHTML={nameHTML}></div>
<PresenceLabel activeAgo={ activeAgo } <PresenceLabel activeAgo={ activeAgo }
currentlyActive={this.props.presenceCurrentlyActive} currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState} /> presenceState={this.props.presenceState} />
@ -101,8 +123,7 @@ module.exports = React.createClass({
} }
else { else {
nameEl = ( nameEl = (
<div className="mx_EntityTile_name"> <div className="mx_EntityTile_name" dangerouslySetInnerHTML={nameHTML}>
{ this.props.name }
</div> </div>
); );
} }

View File

@ -23,7 +23,7 @@ var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg') var MatrixClientPeg = require('../../../MatrixClientPeg')
var TextForEvent = require('../../../TextForEvent'); var TextForEvent = require('../../../TextForEvent');
var ContextualMenu = require('../../../ContextualMenu'); var ContextualMenu = require('../../structures/ContextualMenu');
var dispatcher = require("../../../dispatcher"); var dispatcher = require("../../../dispatcher");
var ObjectUtils = require('../../../ObjectUtils'); var ObjectUtils = require('../../../ObjectUtils');
@ -101,6 +101,9 @@ module.exports = React.createClass({
/* link URL for the highlights */ /* link URL for the highlights */
highlightLink: React.PropTypes.string, highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* is this the focused event */ /* is this the focused event */
isSelectedEvent: React.PropTypes.bool, isSelectedEvent: React.PropTypes.bool,
@ -139,7 +142,8 @@ module.exports = React.createClass({
componentDidMount: function() { componentDidMount: function() {
this._suppressReadReceiptAnimation = false; this._suppressReadReceiptAnimation = false;
MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified); MatrixClientPeg.get().on("deviceVerificationChanged",
this.onDeviceVerificationChanged);
}, },
componentWillReceiveProps: function (nextProps) { componentWillReceiveProps: function (nextProps) {
@ -163,11 +167,12 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
if (client) { if (client) {
client.removeListener("deviceVerified", this.onDeviceVerified); client.removeListener("deviceVerificationChanged",
this.onDeviceVerificationChanged);
} }
}, },
onDeviceVerified: function(userId, device) { onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.mxEvent.getSender()) { if (userId == this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent); this._verifyEvent(this.props.mxEvent);
} }
@ -244,12 +249,15 @@ module.exports = React.createClass({
}, },
onEditClicked: function(e) { onEditClicked: function(e) {
var MessageContextMenu = sdk.getComponent('rooms.MessageContextMenu'); var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
var buttonRect = e.target.getBoundingClientRect() var buttonRect = e.target.getBoundingClientRect()
var x = buttonRect.right;
var y = buttonRect.top + (e.target.height / 2); // The window X and Y offsets are to adjust position when zoomed in to page
var x = buttonRect.right + window.pageXOffset;
var y = (buttonRect.top + (e.target.height / 2) + window.pageYOffset) - 19;
var self = this; var self = this;
ContextualMenu.createMenu(MessageContextMenu, { ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent, mxEvent: this.props.mxEvent,
left: x, left: x,
top: y, top: y,
@ -357,6 +365,8 @@ module.exports = React.createClass({
var SenderProfile = sdk.getComponent('messages.SenderProfile'); var SenderProfile = sdk.getComponent('messages.SenderProfile');
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
//console.log("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
var content = this.props.mxEvent.getContent(); var content = this.props.mxEvent.getContent();
var msgtype = content.msgtype; var msgtype = content.msgtype;
@ -418,6 +428,7 @@ module.exports = React.createClass({
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<EventTileType ref="tile" mxEvent={this.props.mxEvent} highlights={this.props.highlights} <EventTileType ref="tile" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} /> onWidgetLoad={this.props.onWidgetLoad} />
</div> </div>
</div> </div>

View File

@ -37,17 +37,14 @@ module.exports = React.createClass({
}, },
componentWillMount: function() { componentWillMount: function() {
this._room = MatrixClientPeg.get().getRoom(this.props.roomId); var cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
this._emailEntity = null; this._emailEntity = null;
// Load the complete user list for inviting new users
// TODO: Keep this list bleeding-edge up-to-date. Practically speaking, // we have to update the list whenever membership changes
// it will do for now not being updated as random new users join different // particularly to avoid bug https://github.com/vector-im/vector-web/issues/1813
// rooms as this list will be reloaded every room swap. this._updateList();
if (this._room) {
this._userList = MatrixClientPeg.get().getUsers().filter((u) => {
return !this._room.hasMembershipState(u.userId, "join");
});
}
}, },
componentDidMount: function() { componentDidMount: function() {
@ -55,6 +52,28 @@ module.exports = React.createClass({
this.onSearchQueryChanged(''); this.onSearchQueryChanged('');
}, },
componentWillUnmount: function() {
var cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.members", this.onRoomStateMember);
}
},
_updateList: function() {
this._room = MatrixClientPeg.get().getRoom(this.props.roomId);
// Load the complete user list for inviting new users
if (this._room) {
this._userList = MatrixClientPeg.get().getUsers().filter((u) => {
return (!this._room.hasMembershipState(u.userId, "join") &&
!this._room.hasMembershipState(u.userId, "invite"));
});
}
},
onRoomStateMember: function(ev, state, member) {
this._updateList();
},
onInvite: function(ev) { onInvite: function(ev) {
this.props.onInvite(this._input); this.props.onInvite(this._input);
}, },

View File

@ -36,32 +36,75 @@ module.exports = React.createClass({
); );
}, },
onBlockClick: function() {
MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.id, true
);
},
onUnblockClick: function() {
MatrixClientPeg.get().setDeviceBlocked(
this.props.userId, this.props.device.id, false
);
},
render: function() { render: function() {
var indicator = null, button = null; var indicator = null, blockButton = null, verifyButton = null;
if (this.props.device.verified) { if (this.props.device.blocked) {
indicator = ( blockButton = (
<div className="mx_MemberDeviceInfo_verified">&#x2714;</div> <div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unblock"
onClick={this.onUnblockClick}>
Unblock
</div>
); );
button = ( } else {
blockButton = (
<div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_block"
onClick={this.onBlockClick}>
Block
</div>
);
}
if (this.props.device.verified) {
verifyButton = (
<div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unverify" <div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_unverify"
onClick={this.onUnverifyClick}> onClick={this.onUnverifyClick}>
Unverify Unverify
</div> </div>
); );
} else { } else {
button = ( verifyButton = (
<div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify" <div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify"
onClick={this.onVerifyClick}> onClick={this.onVerifyClick}>
Verify Verify
</div> </div>
); );
} }
if (this.props.device.blocked) {
indicator = (
<div className="mx_MemberDeviceInfo_blocked">&#x2716;</div>
);
} else if (this.props.device.verified) {
indicator = (
<div className="mx_MemberDeviceInfo_verified">&#x2714;</div>
);
} else {
indicator = (
<div className="mx_MemberDeviceInfo_unverified">?</div>
);
}
var deviceName = this.props.device.display_name || this.props.device.id;
return ( return (
<div className="mx_MemberDeviceInfo"> <div className="mx_MemberDeviceInfo">
<div className="mx_MemberDeviceInfo_deviceId">{this.props.device.id}</div> <div className="mx_MemberDeviceInfo_deviceId">{deviceName}</div>
<div className="mx_MemberDeviceInfo_deviceKey">{this.props.device.key}</div>
{indicator} {indicator}
{button} {verifyButton}
{blockButton}
</div> </div>
); );
}, },

View File

@ -32,6 +32,7 @@ var Modal = require("../../../Modal");
var sdk = require('../../../index'); var sdk = require('../../../index');
var UserSettingsStore = require('../../../UserSettingsStore'); var UserSettingsStore = require('../../../UserSettingsStore');
var createRoom = require('../../../createRoom'); var createRoom = require('../../../createRoom');
import {emojifyText} from '../../../HtmlUtils';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'MemberInfo', displayName: 'MemberInfo',
@ -60,17 +61,21 @@ module.exports = React.createClass({
updating: 0, updating: 0,
devicesLoading: true, devicesLoading: true,
devices: null, devices: null,
existingOneToOneRoomId: null,
} }
}, },
componentWillMount: function() { componentWillMount: function() {
this._cancelDeviceList = null; this._cancelDeviceList = null;
this.setState({
existingOneToOneRoomId: this.getExistingOneToOneRoomId()
});
}, },
componentDidMount: function() { componentDidMount: function() {
this._updateStateForNewMember(this.props.member); this._updateStateForNewMember(this.props.member);
MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified); MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
}, },
componentWillReceiveProps: function(newProps) { componentWillReceiveProps: function(newProps) {
@ -82,14 +87,67 @@ module.exports = React.createClass({
componentWillUnmount: function() { componentWillUnmount: function() {
var client = MatrixClientPeg.get(); var client = MatrixClientPeg.get();
if (client) { if (client) {
client.removeListener("deviceVerified", this.onDeviceVerified); client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
} }
if (this._cancelDeviceList) { if (this._cancelDeviceList) {
this._cancelDeviceList(); this._cancelDeviceList();
} }
}, },
onDeviceVerified: function(userId, device) { getExistingOneToOneRoomId: function() {
const rooms = MatrixClientPeg.get().getRooms();
const userIds = [
this.props.member.userId,
MatrixClientPeg.get().credentials.userId
];
let existingRoomId = null;
let invitedRoomId = null;
// roomId can be null here because of a hack in MatrixChat.onUserClick where we
// abuse this to view users rather than room members.
let currentMembers;
if (this.props.member.roomId) {
const currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId);
currentMembers = currentRoom.getJoinedMembers();
}
// reuse the first private 1:1 we find
existingRoomId = null;
for (let i = 0; i < rooms.length; i++) {
// don't try to reuse public 1:1 rooms
const join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", '');
if (join_rules && join_rules.getContent().join_rule === 'public') continue;
const members = rooms[i].getJoinedMembers();
if (members.length === 2 &&
userIds.indexOf(members[0].userId) !== -1 &&
userIds.indexOf(members[1].userId) !== -1)
{
existingRoomId = rooms[i].roomId;
break;
}
const invited = rooms[i].getMembersWithMembership('invite');
if (members.length === 1 &&
invited.length === 1 &&
userIds.indexOf(members[0].userId) !== -1 &&
userIds.indexOf(invited[0].userId) !== -1 &&
invitedRoomId === null)
{
invitedRoomId = rooms[i].roomId;
// keep looking: we'll use this one if there's nothing better
}
}
if (existingRoomId === null) {
existingRoomId = invitedRoomId;
}
return existingRoomId;
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.member.userId) { if (userId == this.props.member.userId) {
// no need to re-download the whole thing; just update our copy of // no need to re-download the whole thing; just update our copy of
// the list. // the list.
@ -348,61 +406,29 @@ module.exports = React.createClass({
onChatClick: function() { onChatClick: function() {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// TODO: keep existingOneToOneRoomId updated if we see any room member changes anywhere
const useExistingOneToOneRoom = this.state.existingOneToOneRoomId && (this.state.existingOneToOneRoomId !== this.props.member.roomId);
// check if there are any existing rooms with just us and them (1:1) // check if there are any existing rooms with just us and them (1:1)
// If so, just view that room. If not, create a private room with them. // If so, just view that room. If not, create a private room with them.
var self = this; if (useExistingOneToOneRoom) {
var rooms = MatrixClientPeg.get().getRooms();
var userIds = [
this.props.member.userId,
MatrixClientPeg.get().credentials.userId
];
var existingRoomId;
var currentRoom = MatrixClientPeg.get().getRoom(this.props.member.roomId);
var currentMembers = currentRoom.getJoinedMembers();
// if we're currently in a 1:1 with this user, start a new chat
if (currentMembers.length === 2 &&
userIds.indexOf(currentMembers[0].userId) !== -1 &&
userIds.indexOf(currentMembers[1].userId) !== -1)
{
existingRoomId = null;
}
// otherwise reuse the first private 1:1 we find
else {
existingRoomId = null;
for (var i = 0; i < rooms.length; i++) {
// don't try to reuse public 1:1 rooms
var join_rules = rooms[i].currentState.getStateEvents("m.room.join_rules", '');
if (join_rules && join_rules.getContent().join_rule === 'public') continue;
var members = rooms[i].getJoinedMembers();
if (members.length === 2 &&
userIds.indexOf(members[0].userId) !== -1 &&
userIds.indexOf(members[1].userId) !== -1)
{
existingRoomId = rooms[i].roomId;
break;
}
}
}
if (existingRoomId) {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: existingRoomId room_id: this.state.existingOneToOneRoomId,
}); });
this.props.onFinished(); this.props.onFinished();
} }
else { else {
self.setState({ updating: self.state.updating + 1 }); this.setState({ updating: this.state.updating + 1 });
createRoom({ createRoom({
createOpts: { createOpts: {
invite: [this.props.member.userId], invite: [this.props.member.userId],
}, },
}).finally(function() { }).finally(() => {
self.props.onFinished(); this.props.onFinished();
self.setState({ updating: self.state.updating - 1 }); this.setState({ updating: this.state.updating - 1 });
}).done(); }).done();
} }
}, },
@ -535,7 +561,9 @@ module.exports = React.createClass({
return ( return (
<div> <div>
<h3>Devices</h3> <h3>Devices</h3>
{devComponents} <div className="mx_MemberInfo_devices">
{devComponents}
</div>
</div> </div>
); );
}, },
@ -545,7 +573,22 @@ module.exports = React.createClass({
if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
// FIXME: we're referring to a vector component from react-sdk // FIXME: we're referring to a vector component from react-sdk
var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile'); var BottomLeftMenuTile = sdk.getComponent('rooms.BottomLeftMenuTile');
startChat = <BottomLeftMenuTile collapsed={ false } img="img/create-big.svg" label="Start chat" onClick={ this.onChatClick }/>
var label;
if (this.state.existingOneToOneRoomId) {
if (this.state.existingOneToOneRoomId == this.props.member.roomId) {
label = "Start new direct chat";
}
else {
label = "Go to direct chat";
}
}
else {
label = "Start direct chat";
}
startChat = <BottomLeftMenuTile collapsed={ false } img="img/create-big.svg"
label={ label } onClick={ this.onChatClick }/>
} }
if (this.state.updating) { if (this.state.updating) {
@ -594,6 +637,8 @@ module.exports = React.createClass({
</div> </div>
} }
let memberNameHTML = emojifyText(this.props.member.name);
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var PowerSelector = sdk.getComponent('elements.PowerSelector'); var PowerSelector = sdk.getComponent('elements.PowerSelector');
return ( return (
@ -603,7 +648,7 @@ module.exports = React.createClass({
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} /> <MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
</div> </div>
<h2>{ this.props.member.name }</h2> <h2 dangerouslySetInnerHTML={memberNameHTML}></h2>
<div className="mx_MemberInfo_profile"> <div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField"> <div className="mx_MemberInfo_profileField">

View File

@ -54,7 +54,7 @@ module.exports = React.createClass({
this.memberDict = this.getMemberDict(); this.memberDict = this.getMemberDict();
state.members = this.roomMembers(INITIAL_LOAD_NUM_MEMBERS); state.members = this.roomMembers();
return state; return state;
}, },
@ -64,7 +64,10 @@ module.exports = React.createClass({
cli.on("RoomMember.name", this.onRoomMemberName); cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("RoomState.events", this.onRoomStateEvent); cli.on("RoomState.events", this.onRoomStateEvent);
cli.on("Room", this.onRoom); // invites cli.on("Room", this.onRoom); // invites
cli.on("User.presence", this.onUserPresence); // We listen for changes to the lastPresenceTs which is essentially
// listening for all presence events (we display most of not all of
// the information contained in presence events).
cli.on("User.lastPresenceTs", this.onUserLastPresenceTs);
// cli.on("Room.timeline", this.onRoomTimeline); // cli.on("Room.timeline", this.onRoomTimeline);
}, },
@ -75,24 +78,11 @@ module.exports = React.createClass({
cli.removeListener("RoomMember.name", this.onRoomMemberName); cli.removeListener("RoomMember.name", this.onRoomMemberName);
cli.removeListener("RoomState.events", this.onRoomStateEvent); cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom); cli.removeListener("Room", this.onRoom);
cli.removeListener("User.presence", this.onUserPresence); cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs);
// cli.removeListener("Room.timeline", this.onRoomTimeline); // cli.removeListener("Room.timeline", this.onRoomTimeline);
} }
}, },
componentDidMount: function() {
var self = this;
// Lazy-load in more than the first N members
setTimeout(function() {
if (!self.isMounted()) return;
// lazy load to prevent it blocking the first render
self.setState({
members: self.roomMembers()
});
}, 50);
},
/* /*
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
// ignore anything but real-time updates at the end of the room: // ignore anything but real-time updates at the end of the room:
@ -121,7 +111,7 @@ module.exports = React.createClass({
}, },
*/ */
onUserPresence(event, user) { onUserLastPresenceTs(event, user) {
// Attach a SINGLE listener for global presence changes then locate the // Attach a SINGLE listener for global presence changes then locate the
// member tile and re-render it. This is more efficient than every tile // member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener. // evar attaching their own listener.
@ -325,7 +315,7 @@ module.exports = React.createClass({
return all_members; return all_members;
}, },
roomMembers: function(limit) { roomMembers: function() {
var all_members = this.memberDict || {}; var all_members = this.memberDict || {};
var all_user_ids = Object.keys(all_members); var all_user_ids = Object.keys(all_members);
var ConferenceHandler = CallHandler.getConferenceHandler(); var ConferenceHandler = CallHandler.getConferenceHandler();
@ -334,7 +324,7 @@ module.exports = React.createClass({
var to_display = []; var to_display = [];
var count = 0; var count = 0;
for (var i = 0; i < all_user_ids.length && (limit === undefined || count < limit); ++i) { for (var i = 0; i < all_user_ids.length; ++i) {
var user_id = all_user_ids[i]; var user_id = all_user_ids[i];
var m = all_members[user_id]; var m = all_members[user_id];
@ -425,27 +415,7 @@ module.exports = React.createClass({
// For now, let's just order things by timestamp. It's really annoying // For now, let's just order things by timestamp. It's really annoying
// that a user disappears from sight just because they temporarily go offline // that a user disappears from sight just because they temporarily go offline
/* return userB.getLastActiveTs() - userA.getLastActiveTs();
var presenceMap = {
online: 3,
unavailable: 2,
offline: 1
};
var presenceOrdA = userA ? presenceMap[userA.presence] : 0;
var presenceOrdB = userB ? presenceMap[userB.presence] : 0;
if (presenceOrdA != presenceOrdB) {
return presenceOrdB - presenceOrdA;
}
*/
var lastActiveTsA = userA && userA.lastActiveTs ? userA.lastActiveTs : 0;
var lastActiveTsB = userB && userB.lastActiveTs ? userB.lastActiveTs : 0;
// console.log("comparing ts: " + lastActiveTsA + " and " + lastActiveTsB);
return lastActiveTsB - lastActiveTsA;
}, },
onSearchQueryChanged: function(input) { onSearchQueryChanged: function(input) {
@ -462,9 +432,16 @@ module.exports = React.createClass({
var memberList = self.state.members.filter(function(userId) { var memberList = self.state.members.filter(function(userId) {
var m = self.memberDict[userId]; var m = self.memberDict[userId];
if (query && m.name.toLowerCase().indexOf(query) === -1) {
return false; if (query) {
const matchesName = m.name.toLowerCase().indexOf(query) !== -1;
const matchesId = m.userId.toLowerCase().indexOf(query) !== -1;
if (!matchesName && !matchesId) {
return false;
}
} }
return m.membership == membership; return m.membership == membership;
}).map(function(userId) { }).map(function(userId) {
var m = self.memberDict[userId]; var m = self.memberDict[userId];
@ -515,7 +492,7 @@ module.exports = React.createClass({
invitedSection = ( invitedSection = (
<div className="mx_MemberList_invited"> <div className="mx_MemberList_invited">
<h2>Invited</h2> <h2>Invited</h2>
<div autoshow={true} className="mx_MemberList_wrapper"> <div className="mx_MemberList_wrapper">
{invitedMemberTiles} {invitedMemberTiles}
</div> </div>
</div> </div>
@ -544,7 +521,6 @@ module.exports = React.createClass({
<div className="mx_MemberList"> <div className="mx_MemberList">
{inviteMemberListSection} {inviteMemberListSection}
<GeminiScrollbar autoshow={true} <GeminiScrollbar autoshow={true}
relayoutOnUpdate={false}
className="mx_MemberList_joined mx_MemberList_outerWrapper"> className="mx_MemberList_joined mx_MemberList_outerWrapper">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt} <TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}> createOverflowElement={this._createOverflowTile}>

View File

@ -20,54 +20,53 @@ var MatrixClientPeg = require('../../../MatrixClientPeg');
var Modal = require('../../../Modal'); var Modal = require('../../../Modal');
var sdk = require('../../../index'); var sdk = require('../../../index');
var dis = require('../../../dispatcher'); var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete';
import UserSettingsStore from '../../../UserSettingsStore'; import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({ export default class MessageComposer extends React.Component {
displayName: 'MessageComposer', constructor(props, context) {
super(props, context);
this.onCallClick = this.onCallClick.bind(this);
this.onHangupClick = this.onHangupClick.bind(this);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
this.onInputContentChanged = this.onInputContentChanged.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this._tryComplete = this._tryComplete.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
propTypes: { this.state = {
tabComplete: React.PropTypes.any, autocompleteQuery: '',
selection: null,
};
// a callback which is called when the height of the composer is }
// changed due to a change in content.
onResize: React.PropTypes.func,
// js-sdk Room object onUploadClick(ev) {
room: React.PropTypes.object.isRequired,
// string representing the current voip call state
callState: React.PropTypes.string,
// callback when a file to upload is chosen
uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number,
},
onUploadClick: function(ev) {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); let NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, { Modal.createDialog(NeedToRegisterDialog, {
title: "Please Register", title: "Please Register",
description: "Guest users can't upload files. Please register to upload." description: "Guest users can't upload files. Please register to upload.",
}); });
return; return;
} }
this.refs.uploadInput.click(); this.refs.uploadInput.click();
}, }
onUploadFileSelected: function(ev) { onUploadFileSelected(ev) {
var files = ev.target.files; let files = ev.target.files;
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var TintableSvg = sdk.getComponent("elements.TintableSvg"); let TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileList = []; let fileList = [];
for(var i=0; i<files.length; i++) { for (let i=0; i<files.length; i++) {
fileList.push(<li> fileList.push(<li>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name} <TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name}
</li>); </li>);
@ -94,11 +93,11 @@ module.exports = React.createClass({
} }
this.refs.uploadInput.value = null; this.refs.uploadInput.value = null;
} },
}); });
}, }
onHangupClick: function() { onHangupClick() {
var call = CallHandler.getCallForRoom(this.props.room.roomId); var call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall(); //var call = CallHandler.getAnyActiveCall();
if (!call) { if (!call) {
@ -108,27 +107,55 @@ module.exports = React.createClass({
action: 'hangup', action: 'hangup',
// hangup the call for this room, which may not be the room in props // hangup the call for this room, which may not be the room in props
// (e.g. conferences which will hangup the 1:1 room instead) // (e.g. conferences which will hangup the 1:1 room instead)
room_id: call.roomId room_id: call.roomId,
}); });
}, }
onCallClick: function(ev) { onCallClick(ev) {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video", type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId room_id: this.props.room.roomId,
}); });
}, }
onVoiceCallClick: function(ev) { onVoiceCallClick(ev) {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
type: 'voice', type: 'voice',
room_id: this.props.room.roomId room_id: this.props.room.roomId,
}); });
}, }
render: function() { onInputContentChanged(content: string, selection: {start: number, end: number}) {
this.setState({
autocompleteQuery: content,
selection,
});
}
onUpArrow() {
return this.refs.autocomplete.onUpArrow();
}
onDownArrow() {
return this.refs.autocomplete.onDownArrow();
}
_tryComplete(): boolean {
if (this.refs.autocomplete) {
return this.refs.autocomplete.onConfirm();
}
return false;
}
_onAutocompleteConfirm(range, completion) {
if (this.messageComposerInput) {
this.messageComposerInput.onConfirmAutocompletion(range, completion);
}
}
render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'}; var uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
@ -154,12 +181,12 @@ module.exports = React.createClass({
else { else {
callButton = callButton =
<div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call"> <div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call">
<TintableSvg src="img/voice.svg" width="16" height="26"/> <TintableSvg src="img/icon-call.svg" width="35" height="35"/>
</div> </div>;
videoCallButton = videoCallButton =
<div key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call"> <div key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call">
<TintableSvg src="img/call.svg" width="30" height="22"/> <TintableSvg src="img/icons-video.svg" width="35" height="35"/>
</div> </div>;
} }
var canSendMessages = this.props.room.currentState.maySendMessage( var canSendMessages = this.props.room.currentState.maySendMessage(
@ -172,7 +199,7 @@ module.exports = React.createClass({
var uploadButton = ( var uploadButton = (
<div key="controls_upload" className="mx_MessageComposer_upload" <div key="controls_upload" className="mx_MessageComposer_upload"
onClick={this.onUploadClick} title="Upload file"> onClick={this.onUploadClick} title="Upload file">
<TintableSvg src="img/upload.svg" width="19" height="24"/> <TintableSvg src="img/icons-upload.svg" width="35" height="35"/>
<input ref="uploadInput" type="file" <input ref="uploadInput" type="file"
style={uploadInputStyle} style={uploadInputStyle}
multiple multiple
@ -181,8 +208,16 @@ module.exports = React.createClass({
); );
controls.push( controls.push(
<MessageComposerInput key="controls_input" tabComplete={this.props.tabComplete} <MessageComposerInput
onResize={this.props.onResize} room={this.props.room} />, ref={c => this.messageComposerInput = c}
key="controls_input"
onResize={this.props.onResize}
room={this.props.room}
tryComplete={this._tryComplete}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
onContentChanged={this.onInputContentChanged} />,
uploadButton, uploadButton,
hangupButton, hangupButton,
callButton, callButton,
@ -198,6 +233,13 @@ module.exports = React.createClass({
return ( return (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}> <div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
<div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete
ref="autocomplete"
onConfirm={this._onAutocompleteConfirm}
query={this.state.autocompleteQuery}
selection={this.state.selection} />
</div>
<div className="mx_MessageComposer_wrapper"> <div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row"> <div className="mx_MessageComposer_row">
{controls} {controls}
@ -206,5 +248,24 @@ module.exports = React.createClass({
</div> </div>
); );
} }
}); };
MessageComposer.propTypes = {
tabComplete: React.PropTypes.any,
// a callback which is called when the height of the composer is
// changed due to a change in content.
onResize: React.PropTypes.func,
// js-sdk Room object
room: React.PropTypes.object.isRequired,
// string representing the current voip call state
callState: React.PropTypes.string,
// callback when a file to upload is chosen
uploadFile: React.PropTypes.func.isRequired,
// opacity for dynamic UI fading effects
opacity: React.PropTypes.number
};

View File

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
var marked = require("marked"); import marked from 'marked';
marked.setOptions({ marked.setOptions({
renderer: new marked.Renderer(), renderer: new marked.Renderer(),
gfm: true, gfm: true,
@ -24,7 +24,7 @@ marked.setOptions({
pedantic: false, pedantic: false,
sanitize: true, sanitize: true,
smartLists: true, smartLists: true,
smartypants: false smartypants: false,
}); });
import {Editor, EditorState, RichUtils, CompositeDecorator, import {Editor, EditorState, RichUtils, CompositeDecorator,
@ -33,14 +33,14 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
import {stateToMarkdown} from 'draft-js-export-markdown'; import {stateToMarkdown} from 'draft-js-export-markdown';
var MatrixClientPeg = require("../../../MatrixClientPeg"); import MatrixClientPeg from '../../../MatrixClientPeg';
var SlashCommands = require("../../../SlashCommands"); import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
var Modal = require("../../../Modal"); import SlashCommands from '../../../SlashCommands';
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry; import Modal from '../../../Modal';
var sdk = require('../../../index'); import sdk from '../../../index';
var dis = require("../../../dispatcher"); import dis from '../../../dispatcher';
var KeyCode = require("../../../KeyCode"); import KeyCode from '../../../KeyCode';
import * as RichText from '../../../RichText'; import * as RichText from '../../../RichText';
@ -49,8 +49,8 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
const KEY_M = 77; const KEY_M = 77;
// FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p> // FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p>
function mdownToHtml(mdown) { function mdownToHtml(mdown: string): string {
var html = marked(mdown) || ""; let html = marked(mdown) || "";
html = html.trim(); html = html.trim();
// strip start and end <p> tags else you get 'orrible spacing // strip start and end <p> tags else you get 'orrible spacing
if (html.indexOf("<p>") === 0) { if (html.indexOf("<p>") === 0) {
@ -66,23 +66,38 @@ function mdownToHtml(mdown) {
* The textInput part of the MessageComposer * The textInput part of the MessageComposer
*/ */
export default class MessageComposerInput extends React.Component { export default class MessageComposerInput extends React.Component {
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if (e.keyCode === KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
return 'toggle-mode';
}
return getDefaultKeyBinding(e);
}
client: MatrixClient;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.onAction = this.onAction.bind(this); this.onAction = this.onAction.bind(this);
this.onInputClick = this.onInputClick.bind(this); this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this); this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onChange = this.onChange.bind(this); this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
if(isRichtextEnabled == null) { if (isRichtextEnabled == null) {
isRichtextEnabled = 'true'; isRichtextEnabled = 'true';
} }
isRichtextEnabled = isRichtextEnabled === 'true'; isRichtextEnabled = isRichtextEnabled === 'true';
this.state = { this.state = {
isRichtextEnabled: isRichtextEnabled, isRichtextEnabled: isRichtextEnabled,
editorState: null editorState: null,
}; };
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
@ -91,15 +106,6 @@ export default class MessageComposerInput extends React.Component {
this.client = MatrixClientPeg.get(); this.client = MatrixClientPeg.get();
} }
static getKeyBinding(e: SyntheticKeyboardEvent): string {
// C-m => Toggles between rich text and markdown modes
if(e.keyCode == KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) {
return 'toggle-mode';
}
return getDefaultKeyBinding(e);
}
/** /**
* "Does the right thing" to create an EditorState, based on: * "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled * - whether we've got rich text mode enabled
@ -207,11 +213,9 @@ export default class MessageComposerInput extends React.Component {
let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId); let contentJSON = window.sessionStorage.getItem("mx_messagecomposer_input_" + this.roomId);
if (contentJSON) { if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON)); let content = convertFromRaw(JSON.parse(contentJSON));
component.setState({ component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
editorState: component.createEditorState(component.state.isRichtextEnabled, content)
});
} }
} },
}; };
} }
@ -233,7 +237,7 @@ export default class MessageComposerInput extends React.Component {
} }
onAction(payload) { onAction(payload) {
var editor = this.refs.editor; let editor = this.refs.editor;
switch (payload.action) { switch (payload.action) {
case 'focus_composer': case 'focus_composer':
@ -251,7 +255,7 @@ export default class MessageComposerInput extends React.Component {
payload.displayname payload.displayname
); );
this.setState({ this.setState({
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters') editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'),
}); });
editor.focus(); editor.focus();
} }
@ -344,28 +348,31 @@ export default class MessageComposerInput extends React.Component {
this.refs.editor.focus(); this.refs.editor.focus();
} }
onChange(editorState: EditorState) { setEditorState(editorState: EditorState) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState}); this.setState({editorState});
if(editorState.getCurrentContent().hasText()) { if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity() this.onTypingActivity();
} else { } else {
this.onFinishedTyping(); this.onFinishedTyping();
} }
if (this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray()));
}
} }
enableRichtext(enabled: boolean) { enableRichtext(enabled: boolean) {
if (enabled) { if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setState({ this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html)));
editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html))
});
} else { } else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
contentState = ContentState.createFromText(markdown); contentState = ContentState.createFromText(markdown);
this.setState({ this.setEditorState(this.createEditorState(enabled, contentState));
editorState: this.createEditorState(enabled, contentState)
});
} }
window.localStorage.setItem('mx_editor_rte_enabled', enabled); window.localStorage.setItem('mx_editor_rte_enabled', enabled);
@ -376,7 +383,7 @@ export default class MessageComposerInput extends React.Component {
} }
handleKeyCommand(command: string): boolean { handleKeyCommand(command: string): boolean {
if(command === 'toggle-mode') { if (command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled); this.enableRichtext(!this.state.isRichtextEnabled);
return true; return true;
} }
@ -384,7 +391,7 @@ export default class MessageComposerInput extends React.Component {
let newState: ?EditorState = null; let newState: ?EditorState = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown. // Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
if(!this.state.isRichtextEnabled) { if (!this.state.isRichtextEnabled) {
let contentState = this.state.editorState.getCurrentContent(), let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection(); selection = this.state.editorState.getSelection();
@ -392,10 +399,10 @@ export default class MessageComposerInput extends React.Component {
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*
code: text => `\`${text}\`` code: text => `\`${text}\``,
}[command]; }[command];
if(modifyFn) { if (modifyFn) {
newState = EditorState.push( newState = EditorState.push(
this.state.editorState, this.state.editorState,
RichText.modifyText(contentState, selection, modifyFn), RichText.modifyText(contentState, selection, modifyFn),
@ -404,23 +411,26 @@ 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.onChange(newState); this.setEditorState(newState);
return true; return true;
} }
return false; return false;
} }
handleReturn(ev) { handleReturn(ev) {
if(ev.shiftKey) if (ev.shiftKey) {
return false; return false;
}
const contentState = this.state.editorState.getCurrentContent(); const contentState = this.state.editorState.getCurrentContent();
if(!contentState.hasText()) if (!contentState.hasText()) {
return true; return true;
}
let contentText = contentState.getPlainText(), contentHTML; let contentText = contentState.getPlainText(), contentHTML;
@ -489,10 +499,47 @@ export default class MessageComposerInput extends React.Component {
return true; return true;
} }
onUpArrow(e) {
if (this.props.onUpArrow && this.props.onUpArrow()) {
e.preventDefault();
}
}
onDownArrow(e) {
if (this.props.onDownArrow && this.props.onDownArrow()) {
e.preventDefault();
}
}
onTab(e) {
if (this.props.tryComplete) {
if (this.props.tryComplete()) {
e.preventDefault();
}
}
}
onConfirmAutocompletion(range, content: string) {
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()),
content
);
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
this.setEditorState(editorState);
// for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50);
}
render() { render() {
let className = "mx_MessageComposer_input"; let className = "mx_MessageComposer_input";
if(this.state.isRichtextEnabled) { if (this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
} }
@ -502,11 +549,14 @@ export default class MessageComposerInput extends React.Component {
<Editor ref="editor" <Editor ref="editor"
placeholder="Type a message…" placeholder="Type a message…"
editorState={this.state.editorState} editorState={this.state.editorState}
onChange={this.onChange} onChange={this.setEditorState}
keyBindingFn={MessageComposerInput.getKeyBinding} keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand} handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn} handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled} stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
spellCheck={true} /> spellCheck={true} />
</div> </div>
); );
@ -521,5 +571,15 @@ MessageComposerInput.propTypes = {
onResize: React.PropTypes.func, onResize: React.PropTypes.func,
// js-sdk Room object // js-sdk Room object
room: React.PropTypes.object.isRequired room: React.PropTypes.object.isRequired,
// called with current plaintext content (as a string) whenever it changes
onContentChanged: React.PropTypes.func,
onUpArrow: React.PropTypes.func,
onDownArrow: React.PropTypes.func,
// attempts to confirm currently selected completion, returns whether actually confirmed
tryComplete: React.PropTypes.func,
}; };

View File

@ -163,13 +163,13 @@ module.exports = React.createClass({
}; };
return ( return (
<Velociraptor> <Velociraptor
startStyles={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts} >
<MemberAvatar <MemberAvatar
member={this.props.member} member={this.props.member}
width={14} height={14} resizeMethod="crop" width={14} height={14} resizeMethod="crop"
style={style} style={style}
startStyle={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts}
onClick={this.props.onClick} onClick={this.props.onClick}
/> />
</Velociraptor> </Velociraptor>

View File

@ -24,6 +24,7 @@ var Modal = require("../../../Modal");
var linkify = require('linkifyjs'); var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element'); var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix'); var linkifyMatrix = require('../../../linkify-matrix');
import {emojifyText} from '../../../HtmlUtils';
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -211,13 +212,12 @@ module.exports = React.createClass({
roomName = this.props.room.name; roomName = this.props.room.name;
} }
let roomNameHTML = emojifyText(roomName);
name = name =
<div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}> <div className="mx_RoomHeader_name" onClick={this.props.onSettingsClick}>
<div className={ "mx_RoomHeader_nametext " + (settingsHint ? "mx_RoomHeader_settingsHint" : "") } title={ roomName }>{ roomName }</div> <div className={ "mx_RoomHeader_nametext " + (settingsHint ? "mx_RoomHeader_settingsHint" : "") } title={ roomName } dangerouslySetInnerHTML={roomNameHTML}></div>
{ searchStatus } { searchStatus }
<div className="mx_RoomHeader_settingsButton" title="Settings">
<TintableSvg src="img/settings.svg" width="12" height="12"/>
</div>
</div> </div>
} }
@ -263,10 +263,18 @@ module.exports = React.createClass({
); );
} }
var settings_button;
if (this.props.onSettingsClick) {
settings_button =
<div className="mx_RoomHeader_button" onClick={this.props.onSettingsClick} title="Settings">
<TintableSvg src="img/icons-settings-room.svg" width="16" height="16"/>
</div>;
}
var leave_button; var leave_button;
if (this.props.onLeaveClick) { if (this.props.onLeaveClick) {
leave_button = leave_button =
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton" onClick={this.props.onLeaveClick} title="Leave room"> <div className="mx_RoomHeader_button" onClick={this.props.onLeaveClick} title="Leave room">
<TintableSvg src="img/leave.svg" width="26" height="20"/> <TintableSvg src="img/leave.svg" width="26" height="20"/>
</div>; </div>;
} }
@ -274,7 +282,7 @@ module.exports = React.createClass({
var forget_button; var forget_button;
if (this.props.onForgetClick) { if (this.props.onForgetClick) {
forget_button = forget_button =
<div className="mx_RoomHeader_button mx_RoomHeader_leaveButton" onClick={this.props.onForgetClick} title="Forget room"> <div className="mx_RoomHeader_button" onClick={this.props.onForgetClick} title="Forget room">
<TintableSvg src="img/leave.svg" width="26" height="20"/> <TintableSvg src="img/leave.svg" width="26" height="20"/>
</div>; </div>;
} }
@ -288,10 +296,11 @@ module.exports = React.createClass({
if (!this.props.editing) { if (!this.props.editing) {
right_row = right_row =
<div className="mx_RoomHeader_rightRow"> <div className="mx_RoomHeader_rightRow">
{ settings_button }
{ forget_button } { forget_button }
{ leave_button } { leave_button }
<div className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title="Search"> <div className="mx_RoomHeader_button" onClick={this.props.onSearchClick} title="Search">
<TintableSvg src="img/search.svg" width="21" height="19"/> <TintableSvg src="img/icons-search.svg" width="35" height="35"/>
</div> </div>
{ rightPanel_buttons } { rightPanel_buttons }
</div>; </div>;

View File

@ -268,9 +268,11 @@ module.exports = React.createClass({
}, },
_repositionTooltip: function(e) { _repositionTooltip: function(e) {
if (this.tooltip && this.tooltip.parentElement) { // We access the parent of the parent, as the tooltip is inside a container
// Needs refactoring into a better multipurpose tooltip
if (this.tooltip && this.tooltip.parentElement && this.tooltip.parentElement.parentElement) {
var scroll = ReactDOM.findDOMNode(this); var scroll = ReactDOM.findDOMNode(this);
this.tooltip.style.top = (70 + scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px"; this.tooltip.style.top = (3 + scroll.parentElement.offsetTop + this.tooltip.parentElement.parentElement.offsetTop - this._getScrollNode().scrollTop) + "px";
} }
}, },
@ -325,7 +327,6 @@ module.exports = React.createClass({
return ( return (
<GeminiScrollbar className="mx_RoomList_scrollbar" <GeminiScrollbar className="mx_RoomList_scrollbar"
relayoutOnUpdate={false}
autoshow={true} onScroll={ self._repositionTooltips } ref="gemscroll"> autoshow={true} onScroll={ self._repositionTooltips } ref="gemscroll">
<div className="mx_RoomList"> <div className="mx_RoomList">
<RoomSubList list={ self.state.lists['im.vector.fake.invite'] } <RoomSubList list={ self.state.lists['im.vector.fake.invite'] }

View File

@ -33,16 +33,24 @@ module.exports = React.createClass({
// If invited by 3rd party invite, the email address the invite was sent to // If invited by 3rd party invite, the email address the invite was sent to
invitedEmail: React.PropTypes.string, invitedEmail: React.PropTypes.string,
canJoin: React.PropTypes.bool,
// A standard client/server API error object. If supplied, indicates that the
// caller was unable to fetch details about the room for the given reason.
error: React.PropTypes.object,
canPreview: React.PropTypes.bool, canPreview: React.PropTypes.bool,
spinner: React.PropTypes.bool, spinner: React.PropTypes.bool,
room: React.PropTypes.object, room: React.PropTypes.object,
// The alias that was used to access this room, if appropriate
// If given, this will be how the room is referred to (eg.
// in error messages).
roomAlias: React.PropTypes.object,
}, },
getDefaultProps: function() { getDefaultProps: function() {
return { return {
onJoinClick: function() {}, onJoinClick: function() {},
canJoin: false,
canPreview: true, canPreview: true,
}; };
}, },
@ -115,8 +123,24 @@ module.exports = React.createClass({
); );
} }
else if (this.props.canJoin) { else if (this.props.error) {
var name = this.props.room ? this.props.room.name : ""; var name = this.props.roomAlias || "This room";
var error;
if (this.props.error.errcode == 'M_NOT_FOUND') {
error = name + " does not exist";
} else {
error = name + " is not accessible at this time";
}
joinBlock = (
<div>
<div className="mx_RoomPreviewBar_join_text">
{ error }
</div>
</div>
);
}
else {
var name = this.props.room ? this.props.room.name : (this.props.room_alias || "");
name = name ? <b>{ name }</b> : "a room"; name = name ? <b>{ name }</b> : "a room";
joinBlock = ( joinBlock = (
<div> <div>

View File

@ -23,6 +23,14 @@ var Modal = require('../../../Modal');
var ObjectUtils = require("../../../ObjectUtils"); var ObjectUtils = require("../../../ObjectUtils");
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
var ScalarAuthClient = require("../../../ScalarAuthClient"); var ScalarAuthClient = require("../../../ScalarAuthClient");
var UserSettingsStore = require('../../../UserSettingsStore');
// parse a string as an integer; if the input is undefined, or cannot be parsed
// as an integer, return a default.
function parseIntWithDefault(val, def) {
var res = parseInt(val);
return isNaN(res) ? def : res;
}
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomSettings', displayName: 'RoomSettings',
@ -59,9 +67,18 @@ module.exports = React.createClass({
tags_changed: false, tags_changed: false,
tags: tags, tags: tags,
areNotifsMuted: areNotifsMuted, areNotifsMuted: areNotifsMuted,
<<<<<<< HEAD
isRoomPublished: this._originalIsRoomPublished, // loaded async in componentWillMount isRoomPublished: this._originalIsRoomPublished, // loaded async in componentWillMount
scalar_token: null, scalar_token: null,
scalar_error: null, scalar_error: null,
=======
// isRoomPublished is loaded async in componentWillMount so when the component
// inits, the saved value will always be undefined, however getInitialState()
// is also called from the saving code so we must return the correct value here
// if we have it (although this could race if the user saves before we load whether
// the room is published or not).
isRoomPublished: this._originalIsRoomPublished,
>>>>>>> develop
}; };
}, },
@ -209,11 +226,17 @@ module.exports = React.createClass({
} }
}); });
} }
console.log("Performing %s operations", promises.length);
// color scheme // color scheme
promises.push(this.saveColor()); promises.push(this.saveColor());
// url preview settings
promises.push(this.saveUrlPreviewSettings());
// encryption
promises.push(this.saveEncryption());
console.log("Performing %s operations: %s", promises.length, JSON.stringify(promises));
return q.allSettled(promises); return q.allSettled(promises);
}, },
@ -227,6 +250,24 @@ module.exports = React.createClass({
return this.refs.color_settings.saveSettings(); return this.refs.color_settings.saveSettings();
}, },
saveUrlPreviewSettings: function() {
if (!this.refs.url_preview_settings) { return q(); }
return this.refs.url_preview_settings.saveSettings();
},
saveEncryption: function () {
if (!this.refs.encrypt) { return q(); }
var encrypt = this.refs.encrypt.checked;
if (!encrypt) { return q(); }
var roomId = this.props.room.roomId;
return MatrixClientPeg.get().sendStateEvent(
roomId, "m.room.encryption",
{ algorithm: "m.olm.v1.curve25519-aes-sha2" }
);
},
_hasDiff: function(strA, strB) { _hasDiff: function(strA, strB) {
// treat undefined as an empty string because other components may blindly // treat undefined as an empty string because other components may blindly
// call setName("") when there has been no diff made to the name! // call setName("") when there has been no diff made to the name!
@ -261,7 +302,7 @@ module.exports = React.createClass({
power_levels_changed: true power_levels_changed: true
}); });
}, },
_yankValueFromEvent: function(stateEventType, keyName, defaultValue) { _yankValueFromEvent: function(stateEventType, keyName, defaultValue) {
// E.g.("m.room.name","name") would yank the "name" content key from "m.room.name" // E.g.("m.room.name","name") would yank the "name" content key from "m.room.name"
var event = this.props.room.currentState.getStateEvents(stateEventType, ''); var event = this.props.room.currentState.getStateEvents(stateEventType, '');
@ -296,7 +337,7 @@ module.exports = React.createClass({
}, },
}); });
}, },
_onRoomAccessRadioToggle: function(ev) { _onRoomAccessRadioToggle: function(ev) {
// join_rule // join_rule
@ -400,68 +441,72 @@ module.exports = React.createClass({
}, ""); }, "");
}, },
_renderEncryptionSection: function() {
if (!UserSettingsStore.isFeatureEnabled("e2e_encryption")) {
return null;
}
var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState;
var isEncrypted = cli.isRoomEncrypted(this.props.room.roomId);
var text = "Encryption is " + (isEncrypted ? "" : "not ") +
"enabled in this room.";
var button;
if (!isEncrypted &&
roomState.mayClientSendStateEvent("m.room.encryption", cli)) {
button = (
<label>
<input type="checkbox" ref="encrypt" />
Enable encryption (warning: cannot be disabled again!)
</label>
);
}
return (
<div className="mx_RoomSettings_toggles">
<h3>Encryption</h3>
<label>{text}</label>
{button}
</div>
);
},
render: function() { render: function() {
// TODO: go through greying out things you don't have permission to change // TODO: go through greying out things you don't have permission to change
// (or turning them into informative stuff) // (or turning them into informative stuff)
var AliasSettings = sdk.getComponent("room_settings.AliasSettings"); var AliasSettings = sdk.getComponent("room_settings.AliasSettings");
var ColorSettings = sdk.getComponent("room_settings.ColorSettings"); var ColorSettings = sdk.getComponent("room_settings.ColorSettings");
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 power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', '');
var events_levels = (power_levels ? power_levels.getContent().events : {}) || {};
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
var roomState = this.props.room.currentState; var roomState = this.props.room.currentState;
var user_id = cli.credentials.userId; var user_id = cli.credentials.userId;
if (power_levels) { var power_level_event = roomState.getStateEvents('m.room.power_levels', '');
power_levels = power_levels.getContent(); var power_levels = power_level_event ? power_level_event.getContent() : {};
var events_levels = power_levels.events || {};
var user_levels = power_levels.users || {};
var ban_level = parseInt(power_levels.ban); var ban_level = parseIntWithDefault(power_levels.ban, 50);
var kick_level = parseInt(power_levels.kick); var kick_level = parseIntWithDefault(power_levels.kick, 50);
var redact_level = parseInt(power_levels.redact); var redact_level = parseIntWithDefault(power_levels.redact, 50);
var invite_level = parseInt(power_levels.invite || 0); var invite_level = parseIntWithDefault(power_levels.invite, 50);
var send_level = parseInt(power_levels.events_default || 0); var send_level = parseIntWithDefault(power_levels.events_default, 0);
var state_level = parseInt(power_levels.state_default || 50); var state_level = power_level_event ? parseIntWithDefault(power_levels.state_default, 50) : 0;
var default_user_level = parseInt(power_levels.users_default || 0); var default_user_level = parseIntWithDefault(power_levels.users_default, 0);
if (power_levels.ban == undefined) ban_level = 50; var current_user_level = user_levels[user_id];
if (power_levels.kick == undefined) kick_level = 50; if (current_user_level === undefined) {
if (power_levels.redact == undefined) redact_level = 50; current_user_level = default_user_level;
var user_levels = power_levels.users || {};
var current_user_level = user_levels[user_id];
if (current_user_level == undefined) current_user_level = default_user_level;
var power_level_level = events_levels["m.room.power_levels"];
if (power_level_level == undefined) {
power_level_level = state_level;
}
var can_change_levels = current_user_level >= power_level_level;
} else {
var ban_level = 50;
var kick_level = 50;
var redact_level = 50;
var invite_level = 0;
var send_level = 0;
var state_level = 0;
var default_user_level = 0;
var user_levels = [];
var events_levels = [];
var current_user_level = 0;
var power_level_level = 0;
var can_change_levels = false;
} }
var state_default = (parseInt(power_levels ? power_levels.state_default : 0) || 0); var can_change_levels = roomState.mayClientSendStateEvent("m.room.power_levels", cli);
var canSetTag = !cli.isGuest(); var canSetTag = !cli.isGuest();
@ -530,7 +575,7 @@ module.exports = React.createClass({
var tagsSection = null; var tagsSection = null;
if (canSetTag || self.state.tags) { if (canSetTag || self.state.tags) {
var tagsSection = var tagsSection =
<div className="mx_RoomSettings_tags"> <div className="mx_RoomSettings_tags">
Tagged as: { canSetTag ? Tagged as: { canSetTag ?
(tags.map(function(tag, i) { (tags.map(function(tag, i) {
@ -666,10 +711,6 @@ module.exports = React.createClass({
Members only (since they joined) Members only (since they joined)
</label> </label>
</div> </div>
<label className="mx_RoomSettings_encrypt">
<input type="checkbox" />
Encrypt room
</label>
</div> </div>
@ -690,6 +731,8 @@ module.exports = React.createClass({
canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')} canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')}
aliasEvents={this.props.room.currentState.getStateEvents('m.room.aliases')} /> aliasEvents={this.props.room.currentState.getStateEvents('m.room.aliases')} />
<UrlPreviewSettings ref="url_preview_settings" room={this.props.room} />
<h3>Permissions</h3> <h3>Permissions</h3>
<div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings"> <div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings">
<div className="mx_RoomSettings_powerLevel"> <div className="mx_RoomSettings_powerLevel">
@ -737,6 +780,8 @@ module.exports = React.createClass({
{ bannedUsersSection } { bannedUsersSection }
{ this._renderEncryptionSection() }
<h3>Advanced</h3> <h3>Advanced</h3>
<div className="mx_RoomSettings_settings"> <div className="mx_RoomSettings_settings">
This room's internal ID is <code>{ this.props.room.roomId }</code> This room's internal ID is <code>{ this.props.room.roomId }</code>

View File

@ -21,6 +21,8 @@ var classNames = require('classnames');
var dis = require("../../../dispatcher"); var dis = require("../../../dispatcher");
var MatrixClientPeg = require('../../../MatrixClientPeg'); var MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index'); var sdk = require('../../../index');
var ContextualMenu = require('../../structures/ContextualMenu');
import {emojifyText} from '../../../HtmlUtils';
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'RoomTile', displayName: 'RoomTile',
@ -42,13 +44,48 @@ module.exports = React.createClass({
}, },
getInitialState: function() { getInitialState: function() {
return( { hover : false }); var areNotifsMuted = false;
var cli = MatrixClientPeg.get();
if (!cli.isGuest()) {
var roomPushRule = cli.getRoomPushRule("global", this.props.room.roomId);
if (roomPushRule) {
if (0 <= roomPushRule.actions.indexOf("dont_notify")) {
areNotifsMuted = true;
}
}
}
return({
hover : false,
badgeHover : false,
menu: false,
areNotifsMuted: areNotifsMuted,
});
},
onAction: function(payload) {
switch (payload.action) {
case 'notification_change':
// Is the notification about this room?
if (payload.roomId === this.props.room.roomId) {
this.setState( { areNotifsMuted : payload.isMuted });
}
break;
}
},
componentDidMount: function() {
this.dispatcherRef = dis.register(this.onAction);
},
componentWillUnmount: function() {
dis.unregister(this.dispatcherRef);
}, },
onClick: function() { onClick: function() {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: this.props.room.roomId room_id: this.props.room.roomId,
}); });
}, },
@ -60,6 +97,48 @@ module.exports = React.createClass({
this.setState( { hover : false }); this.setState( { hover : false });
}, },
badgeOnMouseEnter: function() {
// Only allow none guests to access the context menu
// and only change it if it needs to change
if (!MatrixClientPeg.get().isGuest() && !this.state.badgeHover) {
this.setState( { badgeHover : true } );
}
},
badgeOnMouseLeave: function() {
this.setState( { badgeHover : false } );
},
onBadgeClicked: function(e) {
// Only allow none guests to access the context menu
if (!MatrixClientPeg.get().isGuest()) {
// If the badge is clicked, then no longer show tooltip
if (this.props.collapsed) {
this.setState({ hover: false });
}
var Menu = sdk.getComponent('context_menus.NotificationStateContextMenu');
var elementRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
var x = elementRect.right + window.pageXOffset + 3;
var y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset) - 53;
var self = this;
ContextualMenu.createMenu(Menu, {
menuWidth: 188,
menuHeight: 126,
chevronOffset: 45,
left: x,
top: y,
room: this.props.room,
onFinished: function() {
self.setState({ menu: false });
}
});
this.setState({ menu: true });
}
},
render: function() { render: function() {
var myUserId = MatrixClientPeg.get().credentials.userId; var myUserId = MatrixClientPeg.get().credentials.userId;
var me = this.props.room.currentState.members[myUserId]; var me = this.props.room.currentState.members[myUserId];
@ -72,42 +151,64 @@ module.exports = React.createClass({
'mx_RoomTile_selected': this.props.selected, 'mx_RoomTile_selected': this.props.selected,
'mx_RoomTile_unread': this.props.unread, 'mx_RoomTile_unread': this.props.unread,
'mx_RoomTile_unreadNotify': notificationCount > 0, 'mx_RoomTile_unreadNotify': notificationCount > 0,
'mx_RoomTile_read': !(this.props.highlight || notificationCount > 0),
'mx_RoomTile_highlight': this.props.highlight, 'mx_RoomTile_highlight': this.props.highlight,
'mx_RoomTile_invited': (me && me.membership == 'invite'), 'mx_RoomTile_invited': (me && me.membership == 'invite'),
'mx_RoomTile_menu': this.state.menu,
});
var avatarClasses = classNames({
'mx_RoomTile_avatar': true,
'mx_RoomTile_mute': this.state.areNotifsMuted,
});
var badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': this.state.badgeHover || this.state.menu,
'mx_RoomTile_badgeMute': this.state.areNotifsMuted,
}); });
// XXX: We should never display raw room IDs, but sometimes the // XXX: We should never display raw room IDs, but sometimes the
// room name js sdk gives is undefined (cannot repro this -- k) // room name js sdk gives is undefined (cannot repro this -- k)
var name = this.props.room.name || this.props.room.roomId; var name = this.props.room.name || this.props.room.roomId;
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
var badge; var badge;
if (this.props.highlight || notificationCount > 0) { var badgeContent;
badge = <div className="mx_RoomTile_badge">{ notificationCount ? notificationCount : '!' }</div>;
if (this.state.badgeHover || this.state.menu) {
badgeContent = "\u00B7\u00B7\u00B7";
} else if (this.props.highlight || notificationCount > 0) {
var limitedCount = (notificationCount > 99) ? '99+' : notificationCount;
badgeContent = notificationCount ? limitedCount : '!';
} else {
badgeContent = '\u200B';
} }
/*
if (this.props.highlight) { if (this.state.areNotifsMuted && !(this.state.badgeHover || this.state.menu)) {
badge = <div className="mx_RoomTile_badge">!</div>; badge = <div className={ badgeClasses } onClick={this.onBadgeClicked} onMouseEnter={this.badgeOnMouseEnter} onMouseLeave={this.badgeOnMouseLeave}><img className="mx_RoomTile_badgeIcon" src="img/icon-context-mute.svg" width="16" height="12" /></div>;
} else {
badge = <div className={ badgeClasses } onClick={this.onBadgeClicked} onMouseEnter={this.badgeOnMouseEnter} onMouseLeave={this.badgeOnMouseLeave}>{ badgeContent }</div>;
} }
else if (this.props.unread) {
badge = <div className="mx_RoomTile_badge">1</div>;
}
var nameCell;
if (badge) {
nameCell = <div className="mx_RoomTile_nameBadge"><div className="mx_RoomTile_name">{name}</div><div className="mx_RoomTile_badgeCell">{badge}</div></div>;
}
else {
nameCell = <div className="mx_RoomTile_name">{name}</div>;
}
*/
var label; var label;
var tooltip;
if (!this.props.collapsed) { if (!this.props.collapsed) {
var className = 'mx_RoomTile_name' + (this.props.isInvite ? ' mx_RoomTile_invite' : ''); var nameClasses = classNames({
'mx_RoomTile_name': true,
'mx_RoomTile_invite': this.props.isInvite,
'mx_RoomTile_mute': this.state.areNotifsMuted,
'mx_RoomTile_badgeShown': this.props.highlight || notificationCount > 0 || this.state.badgeHover || this.state.menu || this.state.areNotifsMuted,
});
let nameHTML = emojifyText(name);
if (this.props.selected) { if (this.props.selected) {
name = <span>{ name }</span>; let nameSelected = <span dangerouslySetInnerHTML={nameHTML}></span>;
label = <div title={ name } onClick={this.onClick} className={ nameClasses }>{ nameSelected }</div>;
} else {
label = <div title={ name } onClick={this.onClick} className={ nameClasses } dangerouslySetInnerHTML={nameHTML}></div>;
} }
label = <div className={ className }>{ name }</div>;
} }
else if (this.state.hover) { else if (this.state.hover) {
var RoomTooltip = sdk.getComponent("rooms.RoomTooltip"); var RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
@ -129,13 +230,16 @@ module.exports = React.createClass({
var connectDropTarget = this.props.connectDropTarget; var connectDropTarget = this.props.connectDropTarget;
return connectDragSource(connectDropTarget( return connectDragSource(connectDropTarget(
<div className={classes} onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}> <div className={classes} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className="mx_RoomTile_avatar"> <div className={avatarClasses}>
<RoomAvatar room={this.props.room} width={24} height={24} /> <RoomAvatar onClick={this.onClick} room={this.props.room} width={24} height={24} />
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ badge }
</div> </div>
{ label }
{ badge }
{ incomingCallBox } { incomingCallBox }
{ tooltip }
</div> </div>
)); ));
} }

View File

@ -179,7 +179,6 @@ var SearchableEntityList = React.createClass({
} }
list = ( list = (
<GeminiScrollbar autoshow={true} <GeminiScrollbar autoshow={true}
relayoutOnUpdate={false}
className="mx_SearchableEntityList_listWrapper"> className="mx_SearchableEntityList_listWrapper">
{ list } { list }
</GeminiScrollbar> </GeminiScrollbar>

View File

@ -24,17 +24,17 @@ module.exports = React.createClass({
displayName: 'TabCompleteBar', displayName: 'TabCompleteBar',
propTypes: { propTypes: {
entries: React.PropTypes.array.isRequired tabComplete: React.PropTypes.object.isRequired
}, },
render: function() { render: function() {
return ( return (
<div className="mx_TabCompleteBar"> <div className="mx_TabCompleteBar">
{this.props.entries.map(function(entry, i) { {this.props.tabComplete.peek(6).map((entry, i) => {
return ( return (
<div key={entry.getKey() || i + ""} <div key={entry.getKey() || i + ""}
className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") } className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") }
onClick={entry.onClick.bind(entry)} > onClick={this.props.tabComplete.onEntryClick.bind(this.props.tabComplete, entry)} >
{entry.getImageJsx()} {entry.getImageJsx()}
<span className="mx_TabCompleteBar_text"> <span className="mx_TabCompleteBar_text">
{entry.getText()} {entry.getText()}

View File

@ -21,29 +21,10 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'ChangeDisplayName', displayName: 'ChangeDisplayName',
propTypes: {
onFinished: React.PropTypes.func
},
getDefaultProps: function() { _getDisplayName: function() {
return {
onFinished: function() {},
};
},
getInitialState: function() {
return {
busy: false,
errorString: null
}
},
componentWillMount: function() {
var cli = MatrixClientPeg.get(); var cli = MatrixClientPeg.get();
this.setState({busy: true}); return cli.getProfileInfo(cli.credentials.userId).then(function(result) {
var self = this;
cli.getProfileInfo(cli.credentials.userId).done(function(result) {
var displayname = result.displayname; var displayname = result.displayname;
if (!displayname) { if (!displayname) {
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
@ -53,68 +34,26 @@ module.exports = React.createClass({
displayname = MatrixClientPeg.get().getUserIdLocalpart(); displayname = MatrixClientPeg.get().getUserIdLocalpart();
} }
} }
return displayname;
self.setState({
displayName: displayname,
busy: false
});
}, function(error) { }, function(error) {
self.setState({ throw new Error("Failed to fetch display name");
errorString: "Failed to fetch display name",
busy: false
});
}); });
}, },
changeDisplayname: function(new_displayname) { _changeDisplayName: function(new_displayname) {
this.setState({ var cli = MatrixClientPeg.get();
busy: true, return cli.setDisplayName(new_displayname).catch(function(e) {
errorString: null, throw new Error("Failed to set display name");
})
var self = this;
MatrixClientPeg.get().setDisplayName(new_displayname).then(function() {
self.setState({
busy: false,
displayName: new_displayname
});
}, function(error) {
self.setState({
busy: false,
errorString: "Failed to set display name"
});
}); });
}, },
edit: function() {
this.refs.displayname_edit.edit()
},
onValueChanged: function(new_value, shouldSubmit) {
if (shouldSubmit) {
this.changeDisplayname(new_value);
}
},
render: function() { render: function() {
if (this.state.busy) { var EditableTextContainer = sdk.getComponent('elements.EditableTextContainer');
var Loader = sdk.getComponent("elements.Spinner"); return (
return ( <EditableTextContainer
<Loader /> getInitialValue={this._getDisplayName}
); placeholder="No display name"
} else if (this.state.errorString) { onSubmit={this._changeDisplayName} />
return ( );
<div className="error">{this.state.errorString}</div>
);
} else {
var EditableText = sdk.getComponent('elements.EditableText');
return (
<EditableText ref="displayname_edit" initialValue={this.state.displayName}
className="mx_EditableText"
placeholderClassName="mx_EditableText_placeholder"
placeholder="No display name"
onValueChanged={this.onValueChanged} />
);
}
} }
}); });

View File

@ -0,0 +1,138 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
export default class DevicesPanel extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
devices: undefined,
deviceLoadError: undefined,
};
this._unmounted = false;
this._renderDevice = this._renderDevice.bind(this);
}
componentDidMount() {
this._loadDevices();
}
componentWillUnmount() {
this._unmounted = true;
}
_loadDevices() {
MatrixClientPeg.get().getDevices().done(
(resp) => {
if (this._unmounted) { return; }
this.setState({devices: resp.devices || []});
},
(error) => {
if (this._unmounted) { return; }
var errtxt;
if (err.httpStatus == 404) {
// 404 probably means the HS doesn't yet support the API.
errtxt = "Your home server does not support device management.";
} else {
console.error("Error loading devices:", error);
errtxt = "Unable to load device list.";
}
this.setState({deviceLoadError: errtxt});
}
);
}
/**
* compare two devices, sorting from most-recently-seen to least-recently-seen
* (and then, for stability, by device id)
*/
_deviceCompare(a, b) {
// return < 0 if a comes before b, > 0 if a comes after b.
const lastSeenDelta =
(b.last_seen_ts || 0) - (a.last_seen_ts || 0);
if (lastSeenDelta !== 0) { return lastSeenDelta; }
const idA = a.device_id;
const idB = b.device_id;
return (idA < idB) ? -1 : (idA > idB) ? 1 : 0;
}
_onDeviceDeleted(device) {
if (this._unmounted) { return; }
// delete the removed device from our list.
const removed_id = device.device_id;
this.setState((state, props) => {
const newDevices = state.devices.filter(
d => { return d.device_id != removed_id }
);
return { devices: newDevices };
});
}
_renderDevice(device) {
var DevicesPanelEntry = sdk.getComponent('settings.DevicesPanelEntry');
return (
<DevicesPanelEntry key={device.device_id} device={device}
onDeleted={()=>{this._onDeviceDeleted(device)}} />
);
}
render() {
const Spinner = sdk.getComponent("elements.Spinner");
if (this.state.deviceLoadError !== undefined) {
const classes = classNames(this.props.className, "error");
return (
<div className={classes}>
{this.state.deviceLoadError}
</div>
);
}
const devices = this.state.devices;
if (devices === undefined) {
// still loading
const classes = this.props.className;
return <Spinner className={classes}/>;
}
devices.sort(this._deviceCompare);
const classes = classNames(this.props.className, "mx_DevicesPanel");
return (
<div className={classes}>
<div className="mx_DevicesPanel_header">
<div className="mx_DevicesPanel_deviceName">Name</div>
<div className="mx_DevicesPanel_deviceLastSeen">Last seen</div>
<div className="mx_DevicesPanel_deviceButtons"></div>
</div>
{devices.map(this._renderDevice)}
</div>
);
}
}

View File

@ -0,0 +1,136 @@
/*
Copyright 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import q from 'q';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
import DateUtils from '../../../DateUtils';
export default class DevicesPanelEntry extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
deleting: false,
deleteError: undefined,
};
this._unmounted = false;
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onDisplayNameChanged = this._onDisplayNameChanged.bind(this);
}
componentWillUnmount() {
this._unmounted = true;
}
_onDisplayNameChanged(value) {
const device = this.props.device;
return MatrixClientPeg.get().setDeviceDetails(device.device_id, {
display_name: value,
}).catch((e) => {
console.error("Error setting device display name", e);
throw new Error("Failed to set display name");
});
}
_onDeleteClick() {
const device = this.props.device;
this.setState({deleting: true});
MatrixClientPeg.get().deleteDevice(device.device_id).done(
() => {
this.props.onDeleted();
if (this._unmounted) { return; }
this.setState({ deleting: false });
},
(e) => {
console.error("Error deleting device", e);
if (this._unmounted) { return; }
this.setState({
deleting: false,
deleteError: "Failed to delete device",
});
}
);
}
render() {
const EditableTextContainer = sdk.getComponent('elements.EditableTextContainer');
const device = this.props.device;
if (this.state.deleting) {
const Spinner = sdk.getComponent("elements.Spinner");
return (
<div className="mx_DevicesPanel_device">
<Spinner />
</div>
);
}
let lastSeen = "";
if (device.last_seen_ts) {
// todo: format the timestamp as "5 minutes ago" or whatever.
const lastSeenDate = new Date(device.last_seen_ts);
lastSeen = device.last_seen_ip + " @ " +
lastSeenDate.toLocaleString();
}
let deleteButton;
if (this.state.deleteError) {
deleteButton = <div className="error">{this.state.deleteError}</div>
} else {
deleteButton = (
<div className="textButton"
onClick={this._onDeleteClick}>
Delete
</div>
);
}
return (
<div className="mx_DevicesPanel_device">
<div className="mx_DevicesPanel_deviceName">
<EditableTextContainer initialValue={device.display_name}
onSubmit={this._onDisplayNameChanged}
placeholder={device.device_id}
/>
</div>
<div className="mx_DevicesPanel_lastSeen">
{lastSeen}
</div>
<div className="mx_DevicesPanel_deviceButtons">
{deleteButton}
</div>
</div>
);
}
}
DevicesPanelEntry.propTypes = {
device: React.PropTypes.object.isRequired,
onDeleted: React.PropTypes.func,
};
DevicesPanelEntry.defaultProps = {
onDeleted: function() {},
};

View File

@ -64,11 +64,15 @@ function createRoom(opts) {
} }
]; ];
var modal = Modal.createDialog(Loader); var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
return client.createRoom(createOpts).finally(function() { return client.createRoom(createOpts).finally(function() {
modal.close(); modal.close();
}).then(function(res) { }).then(function(res) {
// NB createRoom doesn't block on the client seeing the echo that the
// room has been created, so we race here with the client knowing that
// the room exists, causing things like
// https://github.com/vector-im/vector-web/issues/1813
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: res.room_id room_id: res.room_id

View File

@ -1,38 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
function enableEncyption(client, roomId, members) {
members = members.slice(0);
members.push(client.credentials.userId);
// TODO: Check the keys actually match what keys the user has.
// TODO: Don't redownload keys each time.
return client.downloadKeys(members, "forceDownload").then(function(res) {
return client.setRoomEncryption(roomId, {
algorithm: "m.olm.v1.curve25519-aes-sha2",
members: members,
});
})
}
function disableEncryption(client, roomId) {
return client.disableRoomEncryption(roomId);
}
module.exports = {
enableEncryption: enableEncyption,
disableEncryption: disableEncryption,
}

View File

@ -14,6 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
/**
* 'debounces' a function to only execute every n milliseconds.
* Useful when react-sdk gets many, many events but only wants
* to update the interface once for all of them.
*
* Note that the function must not take arguments, since the args
* could be different for each invocarion of the function.
*/
module.exports = function(f, minIntervalMs) { module.exports = function(f, minIntervalMs) {
this.lastCall = 0; this.lastCall = 0;
this.scheduledCall = undefined; this.scheduledCall = undefined;

View File

@ -210,7 +210,7 @@ describe('TimelinePanel', function() {
var N_EVENTS = 600; var N_EVENTS = 600;
// sadly, loading all those events takes a while // sadly, loading all those events takes a while
this.timeout(N_EVENTS * 20); this.timeout(N_EVENTS * 30);
// client.getRoom is called a /lot/ in this test, so replace // client.getRoom is called a /lot/ in this test, so replace
// sinon's spy with a fast noop. // sinon's spy with a fast noop.
@ -220,12 +220,14 @@ describe('TimelinePanel', function() {
for (var i = 0; i < N_EVENTS; i++) { for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage()); timeline.addEvent(mkMessage());
} }
console.log("added events to timeline");
var scrollDefer; var scrollDefer;
var panel = ReactDOM.render( var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={()=>{scrollDefer.resolve()}} />, <TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}} />,
parentDiv parentDiv
); );
console.log("TimelinePanel rendered");
var messagePanel = ReactTestUtils.findRenderedComponentWithType( var messagePanel = ReactTestUtils.findRenderedComponentWithType(
panel, sdk.getComponent('structures.MessagePanel')); panel, sdk.getComponent('structures.MessagePanel'));
@ -236,16 +238,29 @@ describe('TimelinePanel', function() {
// the TimelinePanel fires a scroll event // the TimelinePanel fires a scroll event
var awaitScroll = function() { var awaitScroll = function() {
scrollDefer = q.defer(); scrollDefer = q.defer();
return scrollDefer.promise; return scrollDefer.promise.then(() => {
console.log("got scroll event; scrollTop now " +
scrollingDiv.scrollTop);
});
}; };
function setScrollTop(scrollTop) {
const before = scrollingDiv.scrollTop;
scrollingDiv.scrollTop = scrollTop;
console.log("setScrollTop: before update: " + before +
"; assigned: " + scrollTop +
"; after update: " + scrollingDiv.scrollTop);
}
function backPaginate() { function backPaginate() {
scrollingDiv.scrollTop = 0; console.log("back paginating...");
setScrollTop(0);
return awaitScroll().then(() => { return awaitScroll().then(() => {
if(scrollingDiv.scrollTop > 0) { if(scrollingDiv.scrollTop > 0) {
// need to go further // need to go further
return backPaginate(); return backPaginate();
} }
console.log("paginated to start.");
// hopefully, we got to the start of the timeline // hopefully, we got to the start of the timeline
expect(messagePanel.props.backPaginating).toBe(false); expect(messagePanel.props.backPaginating).toBe(false);
@ -267,6 +282,7 @@ describe('TimelinePanel', function() {
// we should now be able to scroll down, and paginate in the other // we should now be able to scroll down, and paginate in the other
// direction. // direction.
setScrollTop(scrollingDiv.scrollHeight);
scrollingDiv.scrollTop = scrollingDiv.scrollHeight; scrollingDiv.scrollTop = scrollingDiv.scrollHeight;
return awaitScroll(); return awaitScroll();
}).then(() => { }).then(() => {

View File

@ -51,7 +51,7 @@ module.exports.stubClient = function() {
// 'sandbox.restore()' doesn't work correctly on inherited methods, // 'sandbox.restore()' doesn't work correctly on inherited methods,
// so we do this for each method // so we do this for each method
var methods = ['get', 'unset', 'replaceUsingUrls', var methods = ['get', 'unset', 'replaceUsingUrls',
'replaceUsingAccessToken']; 'replaceUsingCreds'];
for (var i = 0; i < methods.length; i++) { for (var i = 0; i < methods.length; i++) {
sandbox.stub(peg, methods[i]); sandbox.stub(peg, methods[i]);
} }