merge in develop

This commit is contained in:
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
/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 package provides the logic and 'controller' parts for the UI components. This
forms one part of a complete matrix client, but it not useable in isolation. It
must be used from a 'skin'. A skin provides:
* The HTML for the UI components (in the form of React `render` methods)
* The CSS for this HTML
* The containing application
This package provides the React components needed to build a Matrix web client
using React. It is not useable in isolation, and instead must must be used from
a 'skin'. A skin provides:
* Customised implementations of presentation components.
* Custom CSS
* The containing application
* Zero or more 'modules' containing non-UI functionality
Skins are modules are exported from such a package in the `lib` directory.
`lib/skins` contains one directory per-skin, named after the skin, and the
`modules` directory contains modules as their javascript files.
**WARNING: As of July 2016, the skinning abstraction is broken due to rapid
development of `matrix-react-sdk` to meet the needs of Vector, the first app
to be built on top of the SDK** (https://github.com/vector-im/vector-web).
Right now `matrix-react-sdk` depends on some functionality from `vector-web`
(e.g. CSS), and `matrix-react-sdk` contains some Vector specific behaviour
(grep for 'vector'). This layering will be fixed asap once Vector development
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
a minimal application that instantiates the basic skin making a working matrix
client.
In the interim, `vector-im/vector-web` and `matrix-org/matrix-react-sdk` should
be considered as a single project (for instance, matrix-react-sdk bugs
are currently filed against vector-im/vector-web rather than this project).
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.
Developer Guide
===============
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
encourage a very modular and reusable architecture, making it easy to
customise and use UI widgets independently of the rest of the SDK and your app.
All code lands on the `develop` branch - `master` is only used for stable releases.
**Please file PRs against `develop`!!**
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.
* Each component has its own:
* View object defined as a React javascript class containing embedded
HTML expressed in React's JSX notation.
* CSS file, which defines the styling specific to that component.
* 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.
React components in matrix-react-sdk are come in two different flavours:
'structures' and 'views'. Structures are stateful components which handle the
more complicated business logic of the app, delegating their actual presentation
rendering to stateless 'view' components. For instance, the RoomView component
that orchestrates the act of visualising the contents of a given Matrix chat room
tracks lots of state for its child components which it passes into them for visual
rendering via props.
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:
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:
* 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
the context of the rest of the app, although this is unusual for any but
the simplest atoms and molecules.
the context of the rest of the app, although this is unusual for any but
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.
'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
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
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
@ -98,30 +119,36 @@ In practice this means:
generally not cool and stop the component from being reused easily in
different places.
* We don't use the atomify library itself, as React already provides most
of the modularity requirements it brings to the table.
Originally `matrix-react-sdk` followed the Atomic design pattern as per
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
components to embed a Matrix client into your app:
Github Issues
=============
* 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.
All issues should be filed under https://github.com/vector-im/vector-web/issues
for now.
OUTDATED: To Create Your Own Skin
=================================
**This is ALL LIES currently, as skinning is currently broken - see the WARNING
section at the top of this readme.**
Skins are modules are exported from such a package in the `lib` directory.
`lib/skins` contains one directory per-skin, named after the skin, and the
`modules` directory contains modules as their javascript files.
A basic skin is provided in the matrix-react-skin package. This also contains
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
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
@ -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.
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
export KARMAFLAGS="--no-colors"
export NVM_DIR="/home/jenkins/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
nvm use 4
@ -14,6 +15,9 @@ npm install
# run the mocha tests
npm run test
# run eslint
npm run lint -- -f checkstyle -o eslint.xml || true
# delete the old tarball, if it exists
rm -f matrix-react-sdk-*.tgz

View File

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

View File

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

View File

@ -38,11 +38,13 @@ class AddThreepid {
*/
addEmailAddress(emailAddress, 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;
return res;
}, 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})`;
}
throw err;

View File

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

View File

@ -52,6 +52,36 @@ function infoForImageFile(imageFile) {
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 {
constructor() {
this.inprogress = [];
@ -74,13 +104,25 @@ class ContentMessages {
var def = q.defer();
if (file.type.indexOf('image/') == 0) {
content.msgtype = 'm.image';
infoForImageFile(file).then(function (imageInfo) {
infoForImageFile(file).then(imageInfo=>{
extend(content.info, imageInfo);
def.resolve();
}, error=>{
content.msgtype = 'm.file';
def.resolve();
});
} else if (file.type.indexOf('audio/') == 0) {
content.msgtype = 'm.audio';
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 {
content.msgtype = 'm.file';
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 highlight = require('highlight.js');
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 = {
allowedTags: [
@ -28,7 +33,7 @@ var sanitizeHtmlParams = {
// deliberately no h1/h2 to stop people shouting.
'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol',
'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: {
// custom ones first:
@ -42,7 +47,9 @@ var sanitizeHtmlParams = {
selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ],
// URL schemes we permit
allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ],
allowedSchemesByTag: {},
allowedSchemesByTag: {
img: [ 'data' ],
},
transformTags: { // custom to matrix
// 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
*
* opts.highlightLink: optional href to add to highlights
* opts.highlightLink: optional href to add to highlighted words
*/
bodyToHtml: function(content, highlights, opts) {
opts = opts || {};
var isHtml = (content.format === "org.matrix.custom.html");
let body = isHtml ? content.formatted_body : escape(content.body);
var safeBody;
if (isHtml) {
// 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
// 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
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;
// 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
// 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
try {
if (highlights && highlights.length > 0) {
var highlighter = new TextHighlighter("mx_EventTile_searchHighlight", opts.highlightLink);
return highlighter.applyHighlights(safeBody, highlights);
}
else {
return safeBody;
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(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) {
@ -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';
// A thing that holds your Matrix Client
var Matrix = require("matrix-js-sdk");
var GuestAccess = require("./GuestAccess");
import Matrix from 'matrix-js-sdk';
import utils from 'matrix-js-sdk/lib/utils';
var matrixClient = null;
var localStorage = window.localStorage;
const localStorage = window.localStorage;
function deviceId() {
// XXX: is Math.random()'s deterministicity a problem here?
@ -35,97 +32,71 @@ function deviceId() {
return id;
}
function createClientForPeg(hs_url, is_url, user_id, access_token, guestAccess) {
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();
}
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);
}
}
interface MatrixClientCreds {
homeserverUrl: string,
identityServerUrl: string,
userId: string,
accessToken: string,
guest: boolean,
}
if (localStorage) {
var hs_url = localStorage.getItem("mx_hs_url");
var is_url = localStorage.getItem("mx_is_url") || 'https://matrix.org';
var access_token = localStorage.getItem("mx_access_token");
var user_id = localStorage.getItem("mx_user_id");
var guestAccess = new GuestAccess(localStorage);
if (access_token && user_id && hs_url) {
console.log("Restoring session for %s", user_id);
createClientForPeg(hs_url, is_url, user_id, access_token, guestAccess);
}
else {
console.log("Session not found.");
}
}
/**
* Wrapper object for handling the js-sdk Matrix Client object in the react-sdk
* Handles the creation/initialisation of client objects.
* This module provides a singleton instance of this class so the 'current'
* Matrix Client object is available easily.
*/
class MatrixClientPeg {
constructor() {
this.matrixClient = null;
class MatrixClient {
constructor(guestAccess) {
this.guestAccess = guestAccess;
// These are the default options used when when the
// client is started in 'start'. These can be altered
// at any time up to after the 'will_start_client'
// event is finished processing.
this.opts = {
initialSyncLimit: 20,
};
}
get() {
return matrixClient;
get(): MatrixClient {
return this.matrixClient;
}
unset() {
matrixClient = null;
this.matrixClient = null;
}
// FIXME, XXX: this all seems very convoluted :(
//
// if we replace the singleton using URLs we bypass our createClientForPeg()
// 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
/**
* Replace this MatrixClientPeg's client with a client instance that has
* Home Server / Identity Server URLs but no credentials
*/
replaceUsingUrls(hs_url, is_url) {
matrixClient = Matrix.createClient({
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!");
}
this._replaceClient(hs_url, is_url);
}
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) {
try {
localStorage.clear();
@ -133,15 +104,19 @@ class MatrixClient {
console.warn("Error clearing local storage", e);
}
}
this.guestAccess.markAsGuest(Boolean(isGuest));
createClientForPeg(hs_url, is_url, user_id, access_token, this.guestAccess);
this._createClient(hs_url, is_url, user_id, access_token, isGuest);
if (localStorage) {
try {
localStorage.setItem("mx_hs_url", hs_url);
localStorage.setItem("mx_is_url", is_url);
localStorage.setItem("mx_user_id", user_id);
localStorage.setItem("mx_access_token", access_token);
console.log("Session persisted for %s", user_id);
if (user_id !== undefined && access_token !== undefined) {
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) {
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!");
}
}
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) {
global.mxMatrixClient = new MatrixClient(new GuestAccess(localStorage));
if (!global.mxMatrixClientPeg) {
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) {
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) {
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;
return res;
}, 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})`;
}
throw err;

View File

@ -1,16 +1,22 @@
import React from 'react';
import {
Editor,
EditorState,
Modifier,
ContentState,
ContentBlock,
convertFromHTML,
DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle,
CompositeDecorator
CompositeDecorator,
SelectionState,
Entity,
} from 'draft-js';
import * as sdk from './index';
import * as emojione from 'emojione';
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>
this is probably not a good idea since <span> is not a block level element but
@ -23,17 +29,18 @@ const STYLES = {
CODE: 'code',
ITALIC: 'em',
STRIKETHROUGH: 's',
UNDERLINE: 'u'
UNDERLINE: 'u',
};
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/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 ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
export function contentStateToHTML(contentState: ContentState): string {
return contentState.getBlockMap().map((block) => {
@ -60,7 +67,7 @@ export function contentStateToHTML(contentState: ContentState): string {
let result = `<${elem}>${content.join('')}</${elem}>`;
// dirty hack because we don't want block level tags by default, but breaks
if(elem === 'span')
if (elem === 'span')
result += '<br />';
return result;
}).join('');
@ -70,6 +77,48 @@ export function HTMLtoContentState(html: string): ContentState {
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.
*/
@ -85,9 +134,10 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
// unused until we make these decorators immutable (autocomplete needed)
let name = member ? member.name : 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 = {
strategy: (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 {
@ -123,6 +173,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
</a>
)
});
markdownDecorators.push(emojiDecorator);
return markdownDecorators;
}
@ -153,7 +204,7 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection
text = "";
for(let currentKey = startKey;
for (let currentKey = startKey;
currentKey && currentKey !== endKey;
currentKey = contentState.getKeyAfter(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);
}
/**
* 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));
var flowStage = self.firstUncompletedStage(flow);
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);
var self = this;
return this.client.requestEmailToken(
return this.client.requestRegisterEmailToken(
this.signupInstance.email,
this.clientSecret,
1, // TODO: Multiple send attempts?
@ -186,8 +186,8 @@ class EmailIdentityStage extends Stage {
var e = {
isFatal: true
};
if (error.errcode == 'THREEPID_IN_USE') {
e.message = "Email in use";
if (error.errcode == 'M_THREEPID_IN_USE') {
e.message = "This email address is already registered";
} else {
e.message = 'Unable to contact the given identity server';
}

View File

@ -17,7 +17,6 @@ limitations under the License.
var MatrixClientPeg = require("./MatrixClientPeg");
var MatrixTools = require("./MatrixTools");
var dis = require("./dispatcher");
var encryption = require("./encryption");
var Tinter = require("./Tinter");
@ -82,32 +81,13 @@ var commands = {
return success(
MatrixClientPeg.get().setRoomAccountData(
room_id, "org.matrix.room.color_scheme", colorScheme
)
)
);
}
}
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
topic: new Command("topic", "<topic>", function(room_id, args) {
if (args) {
@ -132,46 +112,25 @@ var commands = {
}),
// 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) {
var matches = args.match(/^(\S+)$/);
if (matches) {
var room_alias = matches[1];
if (room_alias[0] !== '#') {
return reject("Usage: /join #alias:domain");
return reject(this.getUsage());
}
if (!room_alias.match(/:/)) {
room_alias += ':' + MatrixClientPeg.get().getDomain();
}
// Try to find a room with this alias
// XXX: do we need to do this? Doesn't the JS SDK suppress duplicate attempts to join the same room?
var foundRoom = MatrixTools.getRoomForAlias(
MatrixClientPeg.get().getRooms(),
room_alias
);
dis.dispatch({
action: 'view_room',
room_alias: room_alias,
auto_join: true,
});
if (foundRoom) { // we've already joined this room, view it if it's not archived.
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 success();
}
}
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
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 KEY_TAB = 9;
@ -45,23 +48,39 @@ class TabComplete {
this.isFirstWord = false; // true if you tab-complete on the first word
this.enterTabCompleteTimerId = null;
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) {
this.list = completeList;
onEntryClick(entry) {
if (this.opts.onClickCompletes) {
// assign onClick listeners for each entry to complete the text
this.list.forEach((l) => {
l.onClick = () => {
this.completeTo(l);
}
});
this.completeTo(entry);
}
}
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}
*/
@ -307,6 +326,54 @@ class TabComplete {
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;

View File

@ -69,6 +69,7 @@ class Entry {
class CommandEntry extends Entry {
constructor(cmd, cmdWithArgs) {
super(cmdWithArgs);
this.kind = 'command';
this.cmd = cmd;
}
@ -95,6 +96,7 @@ class MemberEntry extends Entry {
constructor(member) {
super(member.name || member.userId);
this.member = member;
this.kind = 'member';
}
getImageJsx() {
@ -114,24 +116,7 @@ class MemberEntry extends Entry {
}
MemberEntry.fromMemberList = function(members) {
return members.sort(function(a, b) {
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 members.map(function(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 {
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
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() {
@ -56,56 +69,71 @@ module.exports = React.createClass({
}
self.children[c.key] = old;
} 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
var newProps = {
ref: self.collectNode.bind(self, c.key)
};
if (c.props.startStyle && Object.keys(c.props.startStyle).length) {
var startStyle = c.props.startStyle;
if (Array.isArray(startStyle)) {
startStyle = startStyle[0];
}
newProps._restingStyle = c.props.style;
var newProps = {};
var restingStyle = c.props.style;
var startStyles = self.props.startStyles;
if (startStyles.length > 0) {
var startStyle = startStyles[0]
newProps.style = startStyle;
//console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
// apply the enter animations once it's mounted
// console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
}
newProps.ref = (n => self._collectNode(
c.key, n, restingStyle
));
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 (
node &&
this.nodes[k] === undefined &&
node.props.startStyle &&
Object.keys(node.props.startStyle).length
this.props.startStyles.length > 0
) {
var startStyles = this.props.startStyles;
var transitionOpts = this.props.enterTransitionOpts;
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
// to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) {
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
Velocity(domNode, node.props._restingStyle,
Velocity(domNode, restingStyle,
transitionOpts[i-1])
.then(() => {
// once we've reached the resting state, hide the element if
// 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) {
// Velocity stores data on elements using the jQuery .data()
// 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['structures.ContextualMenu'] = require('./components/structures/ContextualMenu');
module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom');
module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat');
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.TextInputDialog'] = require('./components/views/dialogs/TextInputDialog');
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.ProgressBar'] = require('./components/views/elements/ProgressBar');
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.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.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.EntityTile'] = require('./components/views/rooms/EntityTile');
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.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
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.voip.CallView'] = require('./components/views/voip/CallView');
module.exports.components['views.voip.IncomingCallBox'] = require('./components/views/voip/IncomingCallBox');

View File

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

View File

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

View File

@ -21,7 +21,7 @@ var Favico = require('favico.js');
var MatrixClientPeg = require("../../MatrixClientPeg");
var SdkConfig = require("../../SdkConfig");
var Notifier = require("../../Notifier");
var ContextualMenu = require("../../ContextualMenu");
var ContextualMenu = require("./ContextualMenu");
var RoomListSorter = require("../../RoomListSorter");
var UserActivity = require("../../UserActivity");
var Presence = require("../../Presence");
@ -37,6 +37,7 @@ var sdk = require('../../index');
var MatrixTools = require('../../MatrixTools');
var linkifyMatrix = require("../../linkify-matrix");
var KeyCode = require('../../KeyCode');
var Lifecycle = require('../../Lifecycle');
var createRoom = require("../../createRoom");
@ -109,10 +110,14 @@ module.exports = React.createClass({
return window.localStorage.getItem("mx_hs_url");
}
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() {
return this.props.config.fallback_hs_url;
},
@ -127,16 +132,31 @@ module.exports = React.createClass({
return window.localStorage.getItem("mx_is_url");
}
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() {
SdkConfig.put(this.props.config);
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() {
let clientStarted = false;
this._autoRegisterAsGuest = false;
if (this.props.enableGuest) {
if (!this.getCurrentHsUrl()) {
@ -150,13 +170,14 @@ module.exports = React.createClass({
this.props.startingQueryParams.guest_access_token)
{
this._autoRegisterAsGuest = false;
this.onLoggedIn({
Lifecycle.setLoggedIn({
userId: this.props.startingQueryParams.guest_user_id,
accessToken: this.props.startingQueryParams.guest_access_token,
homeserverUrl: this.props.config.default_hs_url,
identityServerUrl: this.props.config.default_is_url,
homeserverUrl: this.getDefaultHsUrl(),
identityServerUrl: this.getDefaultIsUrl(),
guest: true
});
clientStarted = true;
}
else {
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
// logged in client THEN hit the Sign Out button.
this._autoRegisterAsGuest = false;
this.startMatrixClient();
if (!clientStarted) {
Lifecycle.startMatrixClient();
}
}
this.focusComposer = false;
// 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) {
console.log("Registered as guest: %s", creds.user_id);
self._setAutoRegisterAsGuest(false);
self.onLoggedIn({
Lifecycle.setLoggedIn({
userId: creds.user_id,
accessToken: creds.access_token,
homeserverUrl: hsUrl,
@ -254,34 +277,10 @@ module.exports = React.createClass({
var self = this;
switch (payload.action) {
case 'logout':
var guestCreds;
if (MatrixClientPeg.get().isGuest()) {
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.guestCreds = MatrixClientPeg.getCredentials();
}
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,
});
Lifecycle.logout();
break;
case 'start_registration':
var newState = payload.params || {};
@ -307,7 +306,6 @@ module.exports = React.createClass({
if (this.state.logged_in) return;
this.replaceState({
screen: 'login',
guestCreds: this.state.guestCreds,
});
this.notifyNewScreen('login');
break;
@ -317,17 +315,12 @@ module.exports = React.createClass({
});
break;
case 'start_upgrade_registration':
// stash our guest creds so we can backout if needed
this.guestCreds = MatrixClientPeg.getCredentials();
this.replaceState({
screen: "register",
upgradeUsername: MatrixClientPeg.get().getUserIdLocalpart(),
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');
break;
@ -349,10 +342,13 @@ module.exports = React.createClass({
var client = MatrixClientPeg.get();
client.loginWithToken(payload.params.loginToken).done(function(data) {
MatrixClientPeg.replaceUsingAccessToken(
client.getHomeserverUrl(), client.getIdentityServerUrl(),
data.user_id, data.access_token
);
MatrixClientPeg.replaceUsingCreds({
homeserverUrl: client.getHomeserverUrl(),
identityServerUrl: client.getIdentityServerUrl(),
userId: data.user_id,
accessToken: data.access_token,
guest: false,
});
self.setState({
screen: undefined,
logged_in: true
@ -384,7 +380,7 @@ module.exports = React.createClass({
// FIXME: controller shouldn't be loading a view :(
var Loader = sdk.getComponent("elements.Spinner");
var modal = Modal.createDialog(Loader);
var modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner');
d.then(function() {
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
// 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.
this._viewRoom(
payload.room_id, payload.room_alias, payload.show_settings, payload.event_id,
payload.third_party_invite, payload.oob_data
);
this._viewRoom(payload);
break;
case 'view_prev_room':
roomIndexDelta = -1;
@ -425,7 +418,7 @@ module.exports = React.createClass({
}
roomIndex = (roomIndex + roomIndexDelta) % allRooms.length;
if (roomIndex < 0) roomIndex = allRooms.length - 1;
this._viewRoom(allRooms[roomIndex].roomId);
this._viewRoom({ room_id: allRooms[roomIndex].roomId });
break;
case 'view_indexed_room':
var allRooms = RoomListSorter.mostRecentActivityFirst(
@ -433,7 +426,7 @@ module.exports = React.createClass({
);
var roomIndex = payload.roomIndex;
if (allRooms[roomIndex]) {
this._viewRoom(allRooms[roomIndex].roomId);
this._viewRoom({ room_id: allRooms[roomIndex].roomId });
}
break;
case 'view_user_settings':
@ -479,6 +472,15 @@ module.exports = React.createClass({
middleOpacity: payload.middleOpacity,
});
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
//
// eventId is optional and will cause a switch to the context of that
// particular event.
// @param {Object} thirdPartyInvite Object containing data about the third party
// @param {Object} room_info Object containing data about the room to be joined
// @param {string=} room_info.room_id ID of the room to join. One of room_id or room_alias must be given.
// @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.
// @param {string} thirdPartyInvite.inviteSignUrl 3pid invite sign URL
// @param {string} thirdPartyInvite.invitedwithEmail The email address the invite was sent to
// @param {Object} oob_data Object of additional data about the room
// @param {string=} room_info.third_party_invite.inviteSignUrl 3pid invite sign URL
// @param {string=} room_info.third_party_invite.invitedEmail The email address the invite was sent to
// @param {Object=} room_info.oob_data Object of additional data about the room
// that has been passed out-of-band (eg.
// 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
this._updateScrollMap();
this.focusComposer = true;
var newState = {
initialEventId: eventId,
highlightedEventId: eventId,
initialEventId: room_info.event_id,
highlightedEventId: room_info.event_id,
initialEventPixelOffset: undefined,
page_type: this.PageTypes.RoomView,
thirdPartyInvite: thirdPartyInvite,
roomOobData: oob_data,
currentRoomAlias: roomAlias,
thirdPartyInvite: room_info.third_party_invite,
roomOobData: room_info.oob_data,
currentRoomAlias: room_info.room_alias,
autoJoin: room_info.auto_join,
};
if (!roomAlias) {
newState.currentRoomId = roomId;
if (!room_info.room_alias) {
newState.currentRoomId = room_info.room_id;
}
// if we aren't given an explicit event id, look for one in the
// scrollStateMap.
if (!eventId) {
var scrollState = this.scrollStateMap[roomId];
if (!room_info.event_id) {
var scrollState = this.scrollStateMap[room_info.room_id];
if (scrollState) {
newState.initialEventId = scrollState.focussedEvent;
newState.initialEventPixelOffset = scrollState.pixelOffset;
@ -538,8 +546,8 @@ module.exports = React.createClass({
// the new screen yet (we won't be showing it yet)
// The normal case where this happens is navigating
// to the room in the URL bar on page load.
var presentedId = roomAlias || roomId;
var room = MatrixClientPeg.get().getRoom(roomId);
var presentedId = room_info.room_alias || room_info.room_id;
var room = MatrixClientPeg.get().getRoom(room_info.room_id);
if (room) {
var theAlias = MatrixTools.getDisplayAliasForRoom(room);
if (theAlias) presentedId = theAlias;
@ -555,15 +563,15 @@ module.exports = React.createClass({
// Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
}
if (eventId) {
presentedId += "/"+eventId;
if (room_info.event_id) {
presentedId += "/"+room_info.event_id;
}
this.notifyNewScreen('room/'+presentedId);
newState.ready = true;
}
this.setState(newState);
if (this.refs.roomView && showSettings) {
if (this.refs.roomView && room_info.showSettings) {
this.refs.roomView.showSettings(true);
}
},
@ -583,23 +591,36 @@ module.exports = React.createClass({
this.scrollStateMap[roomId] = state;
},
onLoggedIn: function(credentials) {
credentials.guest = Boolean(credentials.guest);
console.log("onLoggedIn => %s (guest=%s)", credentials.userId, credentials.guest);
MatrixClientPeg.replaceUsingAccessToken(
credentials.homeserverUrl, credentials.identityServerUrl,
credentials.userId, credentials.accessToken, credentials.guest
);
/**
* Called when a new logged in session has started
*/
_onLoggedIn: function(credentials) {
this.guestCreds = null;
this.notifyNewScreen('');
this.setState({
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 self = this;
cli.on('sync', function(state, prevState) {
self.updateFavicon(state, prevState);
@ -666,13 +687,6 @@ module.exports = React.createClass({
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
@ -910,12 +924,14 @@ module.exports = React.createClass({
onReturnToGuestClick: function() {
// reanimate our guest login
this.onLoggedIn(this.state.guestCreds);
this.setState({ guestCreds: null });
if (this.guestCreds) {
Lifecycle.setLoggedIn(this.guestCreds);
this.guestCreds = null;
}
},
onRegistered: function(credentials) {
this.onLoggedIn(credentials);
Lifecycle.setLoggedIn(credentials);
// do post-registration stuff
// This now goes straight to user settings
// We use _setPage since if we wait for
@ -1032,6 +1048,7 @@ module.exports = React.createClass({
<RoomView
ref="roomView"
roomAddress={this.state.currentRoomAlias || this.state.currentRoomId}
autoJoin={this.state.autoJoin}
onRoomIdResolved={this.onRoomIdResolved}
eventId={this.state.initialEventId}
thirdPartyInvite={this.state.thirdPartyInvite}
@ -1111,8 +1128,8 @@ module.exports = React.createClass({
email={this.props.startingQueryParams.email}
username={this.state.upgradeUsername}
guestAccessToken={this.state.guestAccessToken}
defaultHsUrl={this.props.config.default_hs_url}
defaultIsUrl={this.props.config.default_is_url}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
brand={this.props.config.brand}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
@ -1120,14 +1137,14 @@ module.exports = React.createClass({
onLoggedIn={this.onRegistered}
onLoginClick={this.onLoginClick}
onRegisterClick={this.onRegisterClick}
onCancelClick={ this.state.guestCreds ? this.onReturnToGuestClick : null }
onCancelClick={this.guestCreds ? this.onReturnToGuestClick : null}
/>
);
} else if (this.state.screen == 'forgot_password') {
return (
<ForgotPassword
defaultHsUrl={this.props.config.default_hs_url}
defaultIsUrl={this.props.config.default_is_url}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
onComplete={this.onLoginClick}
@ -1136,16 +1153,16 @@ module.exports = React.createClass({
} else {
return (
<Login
onLoggedIn={this.onLoggedIn}
onLoggedIn={Lifecycle.setLoggedIn}
onRegisterClick={this.onRegisterClick}
defaultHsUrl={this.props.config.default_hs_url}
defaultIsUrl={this.props.config.default_is_url}
defaultHsUrl={this.getDefaultHsUrl()}
defaultIsUrl={this.getDefaultIsUrl()}
customHsUrl={this.getCurrentHsUrl()}
customIsUrl={this.getCurrentIsUrl()}
fallbackHsUrl={this.getFallbackHsUrl()}
onForgotPasswordClick={this.onForgotPasswordClick}
onLoginAsGuestClick={this.props.enableGuest && this.props.config && this.props.config.default_hs_url ? this._registerAsGuest.bind(this, true) : undefined}
onCancelClick={ this.state.guestCreds ? this.onReturnToGuestClick : null }
onLoginAsGuestClick={this.props.enableGuest && this.props.config && this._registerAsGuest.bind(this, true)}
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.
highlightedEventId: React.PropTypes.string,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
// event after which we should show a read marker
readMarkerEventId: React.PropTypes.string,
@ -365,6 +368,7 @@ module.exports = React.createClass({
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
showUrlPreview={this.props.showUrlPreview}
checkUnmounting={this._isUnmounting}
eventSendStatus={mxEv.status}
last={last} isSelectedEvent={highlight}/>

View File

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

View File

@ -31,16 +31,15 @@ var Modal = require("../../Modal");
var sdk = require('../../index');
var CallHandler = require('../../CallHandler');
var TabComplete = require("../../TabComplete");
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
var CommandEntry = require("../../TabCompleteEntries").CommandEntry;
var Resend = require("../../Resend");
var SlashCommands = require("../../SlashCommands");
var dis = require("../../dispatcher");
var Tinter = require("../../Tinter");
var rate_limited_func = require('../../ratelimitedfunc');
var ObjectUtils = require('../../ObjectUtils');
var MatrixTools = require('../../MatrixTools');
import UserProvider from '../../autocomplete/UserProvider';
var DEBUG = false;
if (DEBUG) {
@ -117,6 +116,11 @@ module.exports = React.createClass({
guestsCanJoin: 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
// the end of the live timeline. It has the effect of hiding the
// '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.accountData", this.onRoomAccountData);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("accountData", this.onAccountData);
this.tabComplete = new TabComplete({
allowLooping: false,
@ -159,10 +164,11 @@ module.exports = React.createClass({
roomId: result.room_id,
roomLoading: !room,
hasUnsentMessages: this._hasUnsentMessages(room),
}, this._updatePeeking);
}, this._onHaveRoom);
}, (err) => {
this.setState({
roomLoading: false,
roomLoadError: err,
});
});
} else {
@ -172,11 +178,11 @@ module.exports = React.createClass({
room: room,
roomLoading: !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:
// - 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)
@ -187,29 +193,47 @@ module.exports = React.createClass({
// 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
// 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) => {
this.setState({
room: room,
roomLoading: false,
});
this._onRoomLoaded(room);
}, (err) => {
// 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.
if (err.errcode == "M_GUEST_ACCESS_FORBIDDEN") {
// This is fine: the room just isn't peekable (we assume).
// NB. We peek if we are not in the room, although if we try to peek into
// a room in which we have a member event (ie. we've left) synapse will just
// send us the same data as we get in the sync (ie. the last events we saw).
var user_is_in_room = null;
if (this.state.room) {
user_is_in_room = this.state.room.hasMembershipState(
MatrixClientPeg.get().credentials.userId, 'join'
);
this._updateAutoComplete();
this.tabComplete.loadEntries(this.state.room);
}
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({
room: room,
roomLoading: false,
});
} else {
throw err;
}
}).done();
} else if (this.state.room) {
this._onRoomLoaded(room);
}, (err) => {
// 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.
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();
this._onRoomLoaded(this.state.room);
}
@ -244,6 +268,7 @@ module.exports = React.createClass({
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.accountData", this.onRoomAccountData);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("accountData", this.onAccountData);
}
window.removeEventListener('resize', this.onResize);
@ -315,6 +340,10 @@ module.exports = React.createClass({
// ignore events for other rooms
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:
// updates from pagination will happen when the paginate completes.
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,
// after a successful peek, or after we join the room).
_onRoomLoaded: function(room) {
this._calculatePeekRules(room);
this._updatePreviewUrlVisibility(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) {
// 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
@ -388,14 +462,23 @@ module.exports = React.createClass({
Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color);
},
onRoomAccountData: function(room, event) {
if (room.roomId == this.props.roomId) {
if (event.getType === "org.matrix.room.color_scheme") {
onAccountData: function(event) {
if (event.getType() === "org.matrix.preview_urls" && this.state.room) {
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();
// XXX: we should validate the event
console.log("Tinter.tint from onRoomAccountData");
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;
}
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
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
// means we have finished joining a room we were previously peeking
@ -422,12 +517,7 @@ module.exports = React.createClass({
joining: false
});
}
if (this.props.ConferenceHandler &&
member.userId === this.props.ConferenceHandler.getConferenceUserIdForRoom(member.roomId)) {
this._updateConfCallNotification();
}
},
}, 500),
_hasUnsentMessages: function(room) {
return this._getUnsentMessages(room).length > 0;
@ -476,8 +566,6 @@ module.exports = React.createClass({
window.addEventListener('resize', this.onResize);
this.onResize();
this._updateTabCompleteList();
// XXX: EVIL HACK to autofocus inviting on empty rooms.
// We use the setTimeout to avoid racing with focus_composer.
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() {
if (this.refs.roomView) {
var roomView = ReactDOM.findDOMNode(this.refs.roomView);
@ -992,7 +1064,7 @@ module.exports = React.createClass({
this.setState({
rejecting: true
});
MatrixClientPeg.get().leave(this.props.roomAddress).done(function() {
MatrixClientPeg.get().leave(this.state.roomId).done(function() {
dis.dispatch({ action: 'view_next_room' });
self.setState({
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() {
var RoomHeader = sdk.getComponent('rooms.RoomHeader');
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'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 (
<div className="mx_RoomView">
<RoomHeader ref="header"
@ -1277,7 +1358,8 @@ module.exports = React.createClass({
<div className="mx_RoomView_auxPanel">
<RoomPreviewBar onJoinClick={ this.onJoinButtonClicked }
onRejectClick={ this.onRejectThreepidInviteButtonClicked }
canJoin={ true } canPreview={ false }
canPreview={ false } error={ this.state.roomLoadError }
roomAlias={room_alias}
spinner={this.state.joining}
inviterName={inviterName}
invitedEmail={invitedEmail}
@ -1315,7 +1397,7 @@ module.exports = React.createClass({
<RoomPreviewBar onJoinClick={ this.onJoinButtonClicked }
onRejectClick={ this.onRejectButtonClicked }
inviterName={ inviterName }
canJoin={ true } canPreview={ false }
canPreview={ false }
spinner={this.state.joining}
room={this.state.room}
/>
@ -1346,12 +1428,10 @@ module.exports = React.createClass({
statusBar = <UploadBar room={this.state.room} />
} else if (!this.state.searchResults) {
var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar');
var tabEntries = this.tabComplete.isTabCompleting() ?
this.tabComplete.peek(6) : null;
statusBar = <RoomStatusBar
room={this.state.room}
tabCompleteEntries={tabEntries}
tabComplete={this.tabComplete}
numUnreadMessages={this.state.numUnreadMessages}
hasUnsentMessages={this.state.hasUnsentMessages}
atEndOfLiveTimeline={this.state.atEndOfLiveTimeline}
@ -1385,7 +1465,7 @@ module.exports = React.createClass({
invitedEmail = this.props.thirdPartyInvite.invitedEmail;
}
aux = (
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked} canJoin={true}
<RoomPreviewBar onJoinClick={this.onJoinButtonClicked}
onRejectClick={this.onRejectThreepidInviteButtonClicked}
spinner={this.state.joining}
inviterName={inviterName}
@ -1484,6 +1564,8 @@ module.exports = React.createClass({
hideMessagePanel = true;
}
// console.log("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview);
var messagePanel = (
<TimelinePanel ref={this._gatherTimelinePanelRef}
room={this.state.room}
@ -1493,6 +1575,7 @@ module.exports = React.createClass({
eventPixelOffset={this.props.eventPixelOffset}
onScroll={ this.onMessageListScroll }
onReadMarkerUpdated={ this._updateTopUnreadMessagesBar }
showUrlPreview = { this.state.showUrlPreview }
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.
return (<GeminiScrollbar autoshow={true} ref="geminiPanel"
onScroll={this.onScroll} onResize={this.onResize}
relayoutOnUpdate={false}
className={this.props.className} style={this.props.style}>
<div className="mx_RoomView_messageListWrapper">
<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.
eventPixelOffset: React.PropTypes.number,
// Should we show URL Previews
showUrlPreview: React.PropTypes.bool,
// callback which is called when the panel is scrolled.
onScroll: React.PropTypes.func,
@ -934,6 +937,7 @@ var TimelinePanel = React.createClass({
readMarkerEventId={ this.state.readMarkerEventId }
readMarkerVisible={ this.state.readMarkerVisible }
suppressFirstDateSeparator={ this.state.canBackPaginate }
showUrlPreview = { this.props.showUrlPreview }
ourUserId={ MatrixClientPeg.get().credentials.userId }
stickyBottom={ stickyBottom }
onScroll={ this.onMessageListScroll }

View File

@ -214,9 +214,10 @@ module.exports = React.createClass({
onFinished: this.onEmailDialogFinished,
});
}, (err) => {
this.setState({email_add_pending: false});
Modal.createDialog(ErrorDialog, {
title: "Unable to add email address",
description: err.toString()
description: err.message
});
});
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")) {
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() {
var self = this;
var Loader = sdk.getComponent("elements.Spinner");
@ -302,6 +399,7 @@ module.exports = React.createClass({
var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar');
var Notifications = sdk.getComponent("settings.Notifications");
var EditableText = sdk.getComponent('elements.EditableText');
var avatarUrl = (
this.state.avatarUrl ? MatrixClientPeg.get().mxcUrlToHttp(this.state.avatarUrl) : null
);
@ -376,36 +474,11 @@ module.exports = React.createClass({
</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 (
<div className="mx_UserSettings">
<SimpleRoomHeader title="Settings" onCancelClick={ this.props.onClose }/>
<GeminiScrollbar className="mx_UserSettings_body"
relayoutOnUpdate={false}
autoshow={true}>
<h3>Profile</h3>
@ -452,9 +525,10 @@ module.exports = React.createClass({
{notification_area}
{this._renderDeviceInfo()}
{this._renderUserInterfaceSettings()}
{this._renderLabs()}
{this._renderDevicesPanel()}
{this._renderCryptoInfo()}
<h3>Advanced</h3>

View File

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

View File

@ -54,6 +54,16 @@ module.exports = React.createClass({
return {
busy: false,
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;
this.setState({
errorText: "",
busy: true
busy: true,
formVals: formVals,
});
if (formVals.username !== this.props.username) {
@ -228,11 +239,15 @@ module.exports = React.createClass({
break; // NOP
case "Register.START":
case "Register.STEP_m.login.dummy":
// NB. Our 'username' prop is specifically for upgrading
// a guest account
registerStep = (
<RegistrationForm
showEmail={true}
defaultUsername={this.props.username}
defaultEmail={this.props.email}
defaultUsername={this.state.formVals.username}
defaultEmail={this.state.formVals.email}
defaultPassword={this.state.formVals.password}
guestUsername={this.props.username}
minPasswordLength={MIN_PASSWORD_LENGTH}
onError={this.onFormValidationFailed}
onRegisterClick={this.onFormSubmit} />

View File

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

View File

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

View File

@ -126,11 +126,13 @@ module.exports = React.createClass({
render: function() {
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 (
<BaseAvatar {...this.props} name={roomName}
idName={this.props.room ? this.props.room.roomId : null}
<BaseAvatar {...otherProps} name={roomName}
idName={room ? room.roomId : null}
urls={this.state.urls} />
);
}

View File

@ -59,7 +59,7 @@ module.exports = React.createClass({
{this.props.description}
</div>
<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}
</button>
</div>

View File

@ -46,7 +46,7 @@ module.exports = React.createClass({
Sign out?
</div>
<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>
</div>
</div>

View File

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

View File

@ -56,7 +56,7 @@ module.exports = React.createClass({
{this.props.description}
</div>
<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}
</button>

View File

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

View File

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

View File

@ -49,6 +49,8 @@ module.exports = React.createClass({
label: '',
placeholder: '',
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.setAttribute("class", this.props.className);
this.placeholder = false;
}
}
},
getValue: function() {
@ -101,7 +103,7 @@ module.exports = React.createClass({
setValue: function(value) {
this.value = value;
this.showPlaceholder(!this.value);
this.showPlaceholder(!this.value);
},
edit: function() {
@ -125,7 +127,7 @@ module.exports = React.createClass({
onKeyDown: function(ev) {
// console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder);
if (this.placeholder) {
this.showPlaceholder(false);
}
@ -173,7 +175,7 @@ module.exports = React.createClass({
var range = document.createRange();
range.setStart(node, 0);
range.setEnd(node, node.length);
var sel = window.getSelection();
sel.removeAllRanges();
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: {
value: React.PropTypes.number.isRequired,
// if true, the <select/> should be a 'controlled' form element and updated by React
// to reflect the current value, rather than left freeform.
// 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,
onChange: React.PropTypes.func,
},

View File

@ -35,8 +35,16 @@ module.exports = React.createClass({
displayName: 'RegistrationForm',
propTypes: {
// Values pre-filled in the input boxes when the component loads
defaultEmail: 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,
minPasswordLength: React.PropTypes.number,
onError: React.PropTypes.func,
@ -55,10 +63,6 @@ module.exports = React.createClass({
getInitialState: function() {
return {
email: this.props.defaultEmail,
username: null,
password: null,
passwordConfirm: null,
fieldValid: {}
};
},
@ -103,7 +107,7 @@ module.exports = React.createClass({
_doSubmit: function() {
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(),
email: this.refs.email.value.trim()
});
@ -144,7 +148,7 @@ module.exports = React.createClass({
break;
case FIELD_USERNAME:
// 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) {
this.markFieldValid(
field_id,
@ -225,7 +229,7 @@ module.exports = React.createClass({
emailSection = (
<input className="mx_Login_field" type="text" ref="email"
autoFocus={true} placeholder="Email address (optional)"
defaultValue={this.state.email}
defaultValue={this.props.defaultEmail}
style={this._styleField(FIELD_EMAIL)}
onBlur={function() {self.validateField(FIELD_EMAIL)}} />
);
@ -237,8 +241,8 @@ module.exports = React.createClass({
}
var placeholderUserName = "User name";
if (this.props.defaultUsername) {
placeholderUserName += " (default: " + this.props.defaultUsername + ")"
if (this.props.guestUsername) {
placeholderUserName += " (default: " + this.props.guestUsername + ")"
}
return (
@ -247,23 +251,23 @@ module.exports = React.createClass({
{emailSection}
<br />
<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)}
onBlur={function() {self.validateField(FIELD_USERNAME)}} />
<br />
{ this.props.defaultUsername ?
{ this.props.guestUsername ?
<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"
style={this._styleField(FIELD_PASSWORD)}
onBlur={function() {self.validateField(FIELD_PASSWORD)}}
placeholder="Password" defaultValue={this.state.password} />
placeholder="Password" defaultValue={this.props.defaultPassword} />
<br />
<input className="mx_Login_field" type="password" ref="passwordConfirm"
placeholder="Confirm password"
style={this._styleField(FIELD_PASSWORD_CONFIRM)}
onBlur={function() {self.validateField(FIELD_PASSWORD_CONFIRM)}}
defaultValue={this.state.passwordConfirm} />
defaultValue={this.props.defaultPassword} />
<br />
{registerButton}
</form>

View File

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

View File

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

View File

@ -39,6 +39,9 @@ module.exports = React.createClass({
/* link URL for the highlights */
highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* callback for when our widget has loaded */
onWidgetLoad: React.PropTypes.func,
},
@ -56,34 +59,47 @@ module.exports = React.createClass({
componentDidMount: function() {
linkifyElement(this.refs.content, linkifyMatrix.options);
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 });
}
}
this.calculateUrlPreview();
if (this.props.mxEvent.getContent().format === "org.matrix.custom.html")
HtmlUtils.highlightDom(ReactDOM.findDOMNode(this));
},
componentDidUpdate: function() {
this.calculateUrlPreview();
},
shouldComponentUpdate: function(nextProps, nextState) {
//console.log("shouldComponentUpdate: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
// 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() ||
nextProps.highlights !== this.props.highlights ||
nextProps.highlightLink !== this.props.highlightLink ||
nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextState.links !== this.state.links ||
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) {
var links = [];
for (var i = 0; i < nodes.length; i++) {
@ -163,12 +179,14 @@ module.exports = React.createClass({
render: function() {
var mxEvent = this.props.mxEvent;
var content = mxEvent.getContent();
var body = HtmlUtils.bodyToHtml(content, this.props.highlights,
{highlightLink: this.props.highlightLink});
var body = HtmlUtils.bodyToHtml(content, this.props.highlights, {});
if (this.props.highlightLink) {
body = <a href={ this.props.highlightLink }>{ body }</a>;
}
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');
widgets = this.state.links.map((link)=>{
return <LinkPreviewWidget

View File

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

View File

@ -83,13 +83,11 @@ module.exports = React.createClass({
alias: this.state.canonicalAlias
}, ""
)
);
);
}
// save new aliases for m.room.aliases
var aliasOperations = this.getAliasOperations();
var promises = [];
for (var i = 0; i < aliasOperations.length; i++) {
var alias_operation = aliasOperations[i];
console.log("alias %s %s", alias_operation.place, alias_operation.val);
@ -301,7 +299,7 @@ module.exports = React.createClass({
<div className="mx_RoomSettings_addAlias">
<img src="img/plus.svg" width="14" height="14" alt="Add"
onClick={ self.onAliasAdded.bind(self, undefined) }/>
</div>
</div>
</div> : ""
}
</div>

View File

@ -57,7 +57,7 @@ module.exports = React.createClass({
data.primary_color = scheme.primary_color;
data.secondary_color = scheme.secondary_color;
data.index = this._getColorIndex(data);
if (data.index === -1) {
// append the unrecognised colours to our palette
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 sdk = require('../../../index');
import {emojifyText} from '../../../HtmlUtils';
var PRESENCE_CLASS = {
@ -28,6 +29,23 @@ var PRESENCE_CLASS = {
"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({
displayName: 'EntityTile',
@ -78,10 +96,14 @@ module.exports = React.createClass({
},
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 ";
mainClassName += presenceClass + (this.props.className ? (" " + this.props.className) : "");
var nameEl;
let nameHTML = emojifyText(this.props.name);
if (this.state.hover && !this.props.suppressOnHover) {
var activeAgo = this.props.presenceLastActiveAgo ?
@ -92,7 +114,7 @@ module.exports = React.createClass({
nameEl = (
<div className="mx_EntityTile_details">
<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 }
currentlyActive={this.props.presenceCurrentlyActive}
presenceState={this.props.presenceState} />
@ -101,8 +123,7 @@ module.exports = React.createClass({
}
else {
nameEl = (
<div className="mx_EntityTile_name">
{ this.props.name }
<div className="mx_EntityTile_name" dangerouslySetInnerHTML={nameHTML}>
</div>
);
}

View File

@ -23,7 +23,7 @@ var sdk = require('../../../index');
var MatrixClientPeg = require('../../../MatrixClientPeg')
var TextForEvent = require('../../../TextForEvent');
var ContextualMenu = require('../../../ContextualMenu');
var ContextualMenu = require('../../structures/ContextualMenu');
var dispatcher = require("../../../dispatcher");
var ObjectUtils = require('../../../ObjectUtils');
@ -101,6 +101,9 @@ module.exports = React.createClass({
/* link URL for the highlights */
highlightLink: React.PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: React.PropTypes.bool,
/* is this the focused event */
isSelectedEvent: React.PropTypes.bool,
@ -139,7 +142,8 @@ module.exports = React.createClass({
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified);
MatrixClientPeg.get().on("deviceVerificationChanged",
this.onDeviceVerificationChanged);
},
componentWillReceiveProps: function (nextProps) {
@ -163,11 +167,12 @@ module.exports = React.createClass({
componentWillUnmount: function() {
var client = MatrixClientPeg.get();
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()) {
this._verifyEvent(this.props.mxEvent);
}
@ -244,12 +249,15 @@ module.exports = React.createClass({
},
onEditClicked: function(e) {
var MessageContextMenu = sdk.getComponent('rooms.MessageContextMenu');
var MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
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;
ContextualMenu.createMenu(MessageContextMenu, {
chevronOffset: 10,
mxEvent: this.props.mxEvent,
left: x,
top: y,
@ -357,6 +365,8 @@ module.exports = React.createClass({
var SenderProfile = sdk.getComponent('messages.SenderProfile');
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 msgtype = content.msgtype;
@ -418,6 +428,7 @@ module.exports = React.createClass({
<div className="mx_EventTile_line">
<EventTileType ref="tile" mxEvent={this.props.mxEvent} highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onWidgetLoad={this.props.onWidgetLoad} />
</div>
</div>

View File

@ -37,17 +37,14 @@ module.exports = React.createClass({
},
componentWillMount: function() {
this._room = MatrixClientPeg.get().getRoom(this.props.roomId);
var cli = MatrixClientPeg.get();
cli.on("RoomState.members", this.onRoomStateMember);
this._emailEntity = null;
// Load the complete user list for inviting new users
// TODO: Keep this list bleeding-edge up-to-date. Practically speaking,
// it will do for now not being updated as random new users join different
// rooms as this list will be reloaded every room swap.
if (this._room) {
this._userList = MatrixClientPeg.get().getUsers().filter((u) => {
return !this._room.hasMembershipState(u.userId, "join");
});
}
// we have to update the list whenever membership changes
// particularly to avoid bug https://github.com/vector-im/vector-web/issues/1813
this._updateList();
},
componentDidMount: function() {
@ -55,6 +52,28 @@ module.exports = React.createClass({
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) {
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() {
var indicator = null, button = null;
if (this.props.device.verified) {
indicator = (
<div className="mx_MemberDeviceInfo_verified">&#x2714;</div>
var indicator = null, blockButton = null, verifyButton = null;
if (this.props.device.blocked) {
blockButton = (
<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"
onClick={this.onUnverifyClick}>
Unverify
</div>
);
} else {
button = (
verifyButton = (
<div className="mx_MemberDeviceInfo_textButton mx_MemberDeviceInfo_verify"
onClick={this.onVerifyClick}>
Verify
</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 (
<div className="mx_MemberDeviceInfo">
<div className="mx_MemberDeviceInfo_deviceId">{this.props.device.id}</div>
<div className="mx_MemberDeviceInfo_deviceKey">{this.props.device.key}</div>
<div className="mx_MemberDeviceInfo_deviceId">{deviceName}</div>
{indicator}
{button}
{verifyButton}
{blockButton}
</div>
);
},

View File

@ -32,6 +32,7 @@ var Modal = require("../../../Modal");
var sdk = require('../../../index');
var UserSettingsStore = require('../../../UserSettingsStore');
var createRoom = require('../../../createRoom');
import {emojifyText} from '../../../HtmlUtils';
module.exports = React.createClass({
displayName: 'MemberInfo',
@ -60,17 +61,21 @@ module.exports = React.createClass({
updating: 0,
devicesLoading: true,
devices: null,
existingOneToOneRoomId: null,
}
},
componentWillMount: function() {
this._cancelDeviceList = null;
this.setState({
existingOneToOneRoomId: this.getExistingOneToOneRoomId()
});
},
componentDidMount: function() {
this._updateStateForNewMember(this.props.member);
MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified);
MatrixClientPeg.get().on("deviceVerificationChanged", this.onDeviceVerificationChanged);
},
componentWillReceiveProps: function(newProps) {
@ -82,14 +87,67 @@ module.exports = React.createClass({
componentWillUnmount: function() {
var client = MatrixClientPeg.get();
if (client) {
client.removeListener("deviceVerified", this.onDeviceVerified);
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
}
if (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) {
// no need to re-download the whole thing; just update our copy of
// the list.
@ -348,61 +406,29 @@ module.exports = React.createClass({
onChatClick: function() {
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)
// If so, just view that room. If not, create a private room with them.
var self = this;
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) {
if (useExistingOneToOneRoom) {
dis.dispatch({
action: 'view_room',
room_id: existingRoomId
room_id: this.state.existingOneToOneRoomId,
});
this.props.onFinished();
}
else {
self.setState({ updating: self.state.updating + 1 });
this.setState({ updating: this.state.updating + 1 });
createRoom({
createOpts: {
invite: [this.props.member.userId],
},
}).finally(function() {
self.props.onFinished();
self.setState({ updating: self.state.updating - 1 });
}).finally(() => {
this.props.onFinished();
this.setState({ updating: this.state.updating - 1 });
}).done();
}
},
@ -535,7 +561,9 @@ module.exports = React.createClass({
return (
<div>
<h3>Devices</h3>
{devComponents}
<div className="mx_MemberInfo_devices">
{devComponents}
</div>
</div>
);
},
@ -545,7 +573,22 @@ module.exports = React.createClass({
if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) {
// FIXME: we're referring to a vector component from react-sdk
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) {
@ -594,6 +637,8 @@ module.exports = React.createClass({
</div>
}
let memberNameHTML = emojifyText(this.props.member.name);
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
return (
@ -603,7 +648,7 @@ module.exports = React.createClass({
<MemberAvatar onClick={this.onMemberAvatarClick} member={this.props.member} width={48} height={48} />
</div>
<h2>{ this.props.member.name }</h2>
<h2 dangerouslySetInnerHTML={memberNameHTML}></h2>
<div className="mx_MemberInfo_profile">
<div className="mx_MemberInfo_profileField">

View File

@ -54,7 +54,7 @@ module.exports = React.createClass({
this.memberDict = this.getMemberDict();
state.members = this.roomMembers(INITIAL_LOAD_NUM_MEMBERS);
state.members = this.roomMembers();
return state;
},
@ -64,7 +64,10 @@ module.exports = React.createClass({
cli.on("RoomMember.name", this.onRoomMemberName);
cli.on("RoomState.events", this.onRoomStateEvent);
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);
},
@ -75,24 +78,11 @@ module.exports = React.createClass({
cli.removeListener("RoomMember.name", this.onRoomMemberName);
cli.removeListener("RoomState.events", this.onRoomStateEvent);
cli.removeListener("Room", this.onRoom);
cli.removeListener("User.presence", this.onUserPresence);
cli.removeListener("User.lastPresenceTs", this.onUserLastPresenceTs);
// 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) {
// 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
// member tile and re-render it. This is more efficient than every tile
// evar attaching their own listener.
@ -325,7 +315,7 @@ module.exports = React.createClass({
return all_members;
},
roomMembers: function(limit) {
roomMembers: function() {
var all_members = this.memberDict || {};
var all_user_ids = Object.keys(all_members);
var ConferenceHandler = CallHandler.getConferenceHandler();
@ -334,7 +324,7 @@ module.exports = React.createClass({
var to_display = [];
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 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
// that a user disappears from sight just because they temporarily go offline
/*
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;
return userB.getLastActiveTs() - userA.getLastActiveTs();
},
onSearchQueryChanged: function(input) {
@ -462,9 +432,16 @@ module.exports = React.createClass({
var memberList = self.state.members.filter(function(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;
}).map(function(userId) {
var m = self.memberDict[userId];
@ -515,7 +492,7 @@ module.exports = React.createClass({
invitedSection = (
<div className="mx_MemberList_invited">
<h2>Invited</h2>
<div autoshow={true} className="mx_MemberList_wrapper">
<div className="mx_MemberList_wrapper">
{invitedMemberTiles}
</div>
</div>
@ -544,7 +521,6 @@ module.exports = React.createClass({
<div className="mx_MemberList">
{inviteMemberListSection}
<GeminiScrollbar autoshow={true}
relayoutOnUpdate={false}
className="mx_MemberList_joined mx_MemberList_outerWrapper">
<TruncatedList className="mx_MemberList_wrapper" truncateAt={this.state.truncateAt}
createOverflowElement={this._createOverflowTile}>

View File

@ -20,54 +20,53 @@ var MatrixClientPeg = require('../../../MatrixClientPeg');
var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete';
import UserSettingsStore from '../../../UserSettingsStore';
module.exports = React.createClass({
displayName: 'MessageComposer',
export default class MessageComposer extends React.Component {
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: {
tabComplete: React.PropTypes.any,
this.state = {
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
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) {
onUploadClick(ev) {
if (MatrixClientPeg.get().isGuest()) {
var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
let NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog");
Modal.createDialog(NeedToRegisterDialog, {
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;
}
this.refs.uploadInput.click();
},
}
onUploadFileSelected: function(ev) {
var files = ev.target.files;
onUploadFileSelected(ev) {
let files = ev.target.files;
var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
var TintableSvg = sdk.getComponent("elements.TintableSvg");
let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
let TintableSvg = sdk.getComponent("elements.TintableSvg");
var fileList = [];
for(var i=0; i<files.length; i++) {
let fileList = [];
for (let i=0; i<files.length; i++) {
fileList.push(<li>
<TintableSvg key={i} src="img/files.svg" width="16" height="16" /> {files[i].name}
</li>);
@ -94,11 +93,11 @@ module.exports = React.createClass({
}
this.refs.uploadInput.value = null;
}
},
});
},
}
onHangupClick: function() {
onHangupClick() {
var call = CallHandler.getCallForRoom(this.props.room.roomId);
//var call = CallHandler.getAnyActiveCall();
if (!call) {
@ -108,27 +107,55 @@ module.exports = React.createClass({
action: 'hangup',
// 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)
room_id: call.roomId
room_id: call.roomId,
});
},
}
onCallClick: function(ev) {
onCallClick(ev) {
dis.dispatch({
action: 'place_call',
type: ev.shiftKey ? "screensharing" : "video",
room_id: this.props.room.roomId
room_id: this.props.room.roomId,
});
},
}
onVoiceCallClick: function(ev) {
onVoiceCallClick(ev) {
dis.dispatch({
action: 'place_call',
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 uploadInputStyle = {display: 'none'};
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
@ -154,12 +181,12 @@ module.exports = React.createClass({
else {
callButton =
<div key="controls_call" className="mx_MessageComposer_voicecall" onClick={this.onVoiceCallClick} title="Voice call">
<TintableSvg src="img/voice.svg" width="16" height="26"/>
</div>
<TintableSvg src="img/icon-call.svg" width="35" height="35"/>
</div>;
videoCallButton =
<div key="controls_videocall" className="mx_MessageComposer_videocall" onClick={this.onCallClick} title="Video call">
<TintableSvg src="img/call.svg" width="30" height="22"/>
</div>
<TintableSvg src="img/icons-video.svg" width="35" height="35"/>
</div>;
}
var canSendMessages = this.props.room.currentState.maySendMessage(
@ -172,7 +199,7 @@ module.exports = React.createClass({
var uploadButton = (
<div key="controls_upload" className="mx_MessageComposer_upload"
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"
style={uploadInputStyle}
multiple
@ -181,8 +208,16 @@ module.exports = React.createClass({
);
controls.push(
<MessageComposerInput key="controls_input" tabComplete={this.props.tabComplete}
onResize={this.props.onResize} room={this.props.room} />,
<MessageComposerInput
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,
hangupButton,
callButton,
@ -198,6 +233,13 @@ module.exports = React.createClass({
return (
<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_row">
{controls}
@ -206,5 +248,24 @@ module.exports = React.createClass({
</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.
*/
import React from 'react';
var marked = require("marked");
import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
import marked from 'marked';
marked.setOptions({
renderer: new marked.Renderer(),
gfm: true,
@ -24,7 +24,7 @@ marked.setOptions({
pedantic: false,
sanitize: true,
smartLists: true,
smartypants: false
smartypants: false,
});
import {Editor, EditorState, RichUtils, CompositeDecorator,
@ -33,14 +33,14 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
import {stateToMarkdown} from 'draft-js-export-markdown';
var MatrixClientPeg = require("../../../MatrixClientPeg");
var SlashCommands = require("../../../SlashCommands");
var Modal = require("../../../Modal");
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
var sdk = require('../../../index');
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
import SlashCommands from '../../../SlashCommands';
import Modal from '../../../Modal';
import sdk from '../../../index';
var dis = require("../../../dispatcher");
var KeyCode = require("../../../KeyCode");
import dis from '../../../dispatcher';
import KeyCode from '../../../KeyCode';
import * as RichText from '../../../RichText';
@ -49,8 +49,8 @@ const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
const KEY_M = 77;
// FIXME Breaks markdown with multiple paragraphs, since it only strips first and last <p>
function mdownToHtml(mdown) {
var html = marked(mdown) || "";
function mdownToHtml(mdown: string): string {
let html = marked(mdown) || "";
html = html.trim();
// strip start and end <p> tags else you get 'orrible spacing
if (html.indexOf("<p>") === 0) {
@ -66,23 +66,38 @@ function mdownToHtml(mdown) {
* The textInput part of the MessageComposer
*/
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) {
super(props, context);
this.onAction = this.onAction.bind(this);
this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.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');
if(isRichtextEnabled == null) {
if (isRichtextEnabled == null) {
isRichtextEnabled = 'true';
}
isRichtextEnabled = isRichtextEnabled === 'true';
this.state = {
isRichtextEnabled: isRichtextEnabled,
editorState: null
editorState: null,
};
// 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();
}
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:
* - 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);
if (contentJSON) {
let content = convertFromRaw(JSON.parse(contentJSON));
component.setState({
editorState: component.createEditorState(component.state.isRichtextEnabled, content)
});
component.setEditorState(component.createEditorState(component.state.isRichtextEnabled, content));
}
}
},
};
}
@ -233,7 +237,7 @@ export default class MessageComposerInput extends React.Component {
}
onAction(payload) {
var editor = this.refs.editor;
let editor = this.refs.editor;
switch (payload.action) {
case 'focus_composer':
@ -251,7 +255,7 @@ export default class MessageComposerInput extends React.Component {
payload.displayname
);
this.setState({
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters')
editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'),
});
editor.focus();
}
@ -344,28 +348,31 @@ export default class MessageComposerInput extends React.Component {
this.refs.editor.focus();
}
onChange(editorState: EditorState) {
setEditorState(editorState: EditorState) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState});
if(editorState.getCurrentContent().hasText()) {
this.onTypingActivity()
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
} else {
this.onFinishedTyping();
}
if (this.props.onContentChanged) {
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
RichText.selectionStateToTextOffsets(editorState.getSelection(),
editorState.getCurrentContent().getBlocksAsArray()));
}
}
enableRichtext(enabled: boolean) {
if (enabled) {
let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
this.setState({
editorState: this.createEditorState(enabled, RichText.HTMLtoContentState(html))
});
this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html)));
} else {
let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
contentState = ContentState.createFromText(markdown);
this.setState({
editorState: this.createEditorState(enabled, contentState)
});
this.setEditorState(this.createEditorState(enabled, contentState));
}
window.localStorage.setItem('mx_editor_rte_enabled', enabled);
@ -376,7 +383,7 @@ export default class MessageComposerInput extends React.Component {
}
handleKeyCommand(command: string): boolean {
if(command === 'toggle-mode') {
if (command === 'toggle-mode') {
this.enableRichtext(!this.state.isRichtextEnabled);
return true;
}
@ -384,7 +391,7 @@ export default class MessageComposerInput extends React.Component {
let newState: ?EditorState = null;
// 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(),
selection = this.state.editorState.getSelection();
@ -392,10 +399,10 @@ export default class MessageComposerInput extends React.Component {
bold: text => `**${text}**`,
italic: text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
code: text => `\`${text}\``
code: text => `\`${text}\``,
}[command];
if(modifyFn) {
if (modifyFn) {
newState = EditorState.push(
this.state.editorState,
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);
if (newState != null) {
this.onChange(newState);
this.setEditorState(newState);
return true;
}
return false;
}
handleReturn(ev) {
if(ev.shiftKey)
if (ev.shiftKey) {
return false;
}
const contentState = this.state.editorState.getCurrentContent();
if(!contentState.hasText())
if (!contentState.hasText()) {
return true;
}
let contentText = contentState.getPlainText(), contentHTML;
@ -489,10 +499,47 @@ export default class MessageComposerInput extends React.Component {
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() {
let className = "mx_MessageComposer_input";
if(this.state.isRichtextEnabled) {
if (this.state.isRichtextEnabled) {
className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
}
@ -502,11 +549,14 @@ export default class MessageComposerInput extends React.Component {
<Editor ref="editor"
placeholder="Type a message…"
editorState={this.state.editorState}
onChange={this.onChange}
onChange={this.setEditorState}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
spellCheck={true} />
</div>
);
@ -521,5 +571,15 @@ MessageComposerInput.propTypes = {
onResize: React.PropTypes.func,
// 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 (
<Velociraptor>
<Velociraptor
startStyles={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts} >
<MemberAvatar
member={this.props.member}
width={14} height={14} resizeMethod="crop"
style={style}
startStyle={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts}
onClick={this.props.onClick}
/>
</Velociraptor>

View File

@ -24,6 +24,7 @@ var Modal = require("../../../Modal");
var linkify = require('linkifyjs');
var linkifyElement = require('linkifyjs/element');
var linkifyMatrix = require('../../../linkify-matrix');
import {emojifyText} from '../../../HtmlUtils';
linkifyMatrix(linkify);
@ -211,13 +212,12 @@ module.exports = React.createClass({
roomName = this.props.room.name;
}
let roomNameHTML = emojifyText(roomName);
name =
<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 }
<div className="mx_RoomHeader_settingsButton" title="Settings">
<TintableSvg src="img/settings.svg" width="12" height="12"/>
</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;
if (this.props.onLeaveClick) {
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"/>
</div>;
}
@ -274,7 +282,7 @@ module.exports = React.createClass({
var forget_button;
if (this.props.onForgetClick) {
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"/>
</div>;
}
@ -288,10 +296,11 @@ module.exports = React.createClass({
if (!this.props.editing) {
right_row =
<div className="mx_RoomHeader_rightRow">
{ settings_button }
{ forget_button }
{ leave_button }
<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>
{ rightPanel_buttons }
</div>;

View File

@ -268,9 +268,11 @@ module.exports = React.createClass({
},
_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);
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 (
<GeminiScrollbar className="mx_RoomList_scrollbar"
relayoutOnUpdate={false}
autoshow={true} onScroll={ self._repositionTooltips } ref="gemscroll">
<div className="mx_RoomList">
<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
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,
spinner: React.PropTypes.bool,
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() {
return {
onJoinClick: function() {},
canJoin: false,
canPreview: true,
};
},
@ -115,8 +123,24 @@ module.exports = React.createClass({
);
}
else if (this.props.canJoin) {
var name = this.props.room ? this.props.room.name : "";
else if (this.props.error) {
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";
joinBlock = (
<div>

View File

@ -23,6 +23,14 @@ var Modal = require('../../../Modal');
var ObjectUtils = require("../../../ObjectUtils");
var dis = require("../../../dispatcher");
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({
displayName: 'RoomSettings',
@ -59,9 +67,18 @@ module.exports = React.createClass({
tags_changed: false,
tags: tags,
areNotifsMuted: areNotifsMuted,
<<<<<<< HEAD
isRoomPublished: this._originalIsRoomPublished, // loaded async in componentWillMount
scalar_token: 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
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);
},
@ -227,6 +250,24 @@ module.exports = React.createClass({
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) {
// treat undefined as an empty string because other components may blindly
// call setName("") when there has been no diff made to the name!
@ -261,7 +302,7 @@ module.exports = React.createClass({
power_levels_changed: true
});
},
_yankValueFromEvent: function(stateEventType, keyName, defaultValue) {
// E.g.("m.room.name","name") would yank the "name" content key from "m.room.name"
var event = this.props.room.currentState.getStateEvents(stateEventType, '');
@ -296,7 +337,7 @@ module.exports = React.createClass({
},
});
},
_onRoomAccessRadioToggle: function(ev) {
// 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() {
// TODO: go through greying out things you don't have permission to change
// (or turning them into informative stuff)
var AliasSettings = sdk.getComponent("room_settings.AliasSettings");
var ColorSettings = sdk.getComponent("room_settings.ColorSettings");
var UrlPreviewSettings = sdk.getComponent("room_settings.UrlPreviewSettings");
var EditableText = sdk.getComponent('elements.EditableText');
var PowerSelector = sdk.getComponent('elements.PowerSelector');
var Loader = sdk.getComponent("elements.Spinner")
var 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 roomState = this.props.room.currentState;
var user_id = cli.credentials.userId;
if (power_levels) {
power_levels = power_levels.getContent();
var power_level_event = roomState.getStateEvents('m.room.power_levels', '');
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 kick_level = parseInt(power_levels.kick);
var redact_level = parseInt(power_levels.redact);
var invite_level = parseInt(power_levels.invite || 0);
var send_level = parseInt(power_levels.events_default || 0);
var state_level = parseInt(power_levels.state_default || 50);
var default_user_level = parseInt(power_levels.users_default || 0);
var ban_level = parseIntWithDefault(power_levels.ban, 50);
var kick_level = parseIntWithDefault(power_levels.kick, 50);
var redact_level = parseIntWithDefault(power_levels.redact, 50);
var invite_level = parseIntWithDefault(power_levels.invite, 50);
var send_level = parseIntWithDefault(power_levels.events_default, 0);
var state_level = power_level_event ? parseIntWithDefault(power_levels.state_default, 50) : 0;
var default_user_level = parseIntWithDefault(power_levels.users_default, 0);
if (power_levels.ban == undefined) ban_level = 50;
if (power_levels.kick == undefined) kick_level = 50;
if (power_levels.redact == undefined) redact_level = 50;
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 current_user_level = user_levels[user_id];
if (current_user_level === undefined) {
current_user_level = default_user_level;
}
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();
@ -530,7 +575,7 @@ module.exports = React.createClass({
var tagsSection = null;
if (canSetTag || self.state.tags) {
var tagsSection =
var tagsSection =
<div className="mx_RoomSettings_tags">
Tagged as: { canSetTag ?
(tags.map(function(tag, i) {
@ -666,10 +711,6 @@ module.exports = React.createClass({
Members only (since they joined)
</label>
</div>
<label className="mx_RoomSettings_encrypt">
<input type="checkbox" />
Encrypt room
</label>
</div>
@ -690,6 +731,8 @@ module.exports = React.createClass({
canonicalAliasEvent={this.props.room.currentState.getStateEvents('m.room.canonical_alias', '')}
aliasEvents={this.props.room.currentState.getStateEvents('m.room.aliases')} />
<UrlPreviewSettings ref="url_preview_settings" room={this.props.room} />
<h3>Permissions</h3>
<div className="mx_RoomSettings_powerLevels mx_RoomSettings_settings">
<div className="mx_RoomSettings_powerLevel">
@ -737,6 +780,8 @@ module.exports = React.createClass({
{ bannedUsersSection }
{ this._renderEncryptionSection() }
<h3>Advanced</h3>
<div className="mx_RoomSettings_settings">
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 MatrixClientPeg = require('../../../MatrixClientPeg');
var sdk = require('../../../index');
var ContextualMenu = require('../../structures/ContextualMenu');
import {emojifyText} from '../../../HtmlUtils';
module.exports = React.createClass({
displayName: 'RoomTile',
@ -42,13 +44,48 @@ module.exports = React.createClass({
},
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() {
dis.dispatch({
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 });
},
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() {
var myUserId = MatrixClientPeg.get().credentials.userId;
var me = this.props.room.currentState.members[myUserId];
@ -72,42 +151,64 @@ module.exports = React.createClass({
'mx_RoomTile_selected': this.props.selected,
'mx_RoomTile_unread': this.props.unread,
'mx_RoomTile_unreadNotify': notificationCount > 0,
'mx_RoomTile_read': !(this.props.highlight || notificationCount > 0),
'mx_RoomTile_highlight': this.props.highlight,
'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
// room name js sdk gives is undefined (cannot repro this -- k)
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
var badge;
if (this.props.highlight || notificationCount > 0) {
badge = <div className="mx_RoomTile_badge">{ notificationCount ? notificationCount : '!' }</div>;
var badgeContent;
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) {
badge = <div className="mx_RoomTile_badge">!</div>;
if (this.state.areNotifsMuted && !(this.state.badgeHover || this.state.menu)) {
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 tooltip;
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) {
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) {
var RoomTooltip = sdk.getComponent("rooms.RoomTooltip");
@ -129,13 +230,16 @@ module.exports = React.createClass({
var connectDropTarget = this.props.connectDropTarget;
return connectDragSource(connectDropTarget(
<div className={classes} onClick={this.onClick} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className="mx_RoomTile_avatar">
<RoomAvatar room={this.props.room} width={24} height={24} />
<div className={classes} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
<div className={avatarClasses}>
<RoomAvatar onClick={this.onClick} room={this.props.room} width={24} height={24} />
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ badge }
</div>
{ label }
{ badge }
{ incomingCallBox }
{ tooltip }
</div>
));
}

View File

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

View File

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

View File

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

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() {
modal.close();
}).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({
action: 'view_room',
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.
*/
/**
* '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) {
this.lastCall = 0;
this.scheduledCall = undefined;

View File

@ -210,7 +210,7 @@ describe('TimelinePanel', function() {
var N_EVENTS = 600;
// 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
// sinon's spy with a fast noop.
@ -220,12 +220,14 @@ describe('TimelinePanel', function() {
for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage());
}
console.log("added events to timeline");
var scrollDefer;
var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={()=>{scrollDefer.resolve()}} />,
<TimelinePanel room={room} onScroll={() => {scrollDefer.resolve()}} />,
parentDiv
);
console.log("TimelinePanel rendered");
var messagePanel = ReactTestUtils.findRenderedComponentWithType(
panel, sdk.getComponent('structures.MessagePanel'));
@ -236,16 +238,29 @@ describe('TimelinePanel', function() {
// the TimelinePanel fires a scroll event
var awaitScroll = function() {
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() {
scrollingDiv.scrollTop = 0;
console.log("back paginating...");
setScrollTop(0);
return awaitScroll().then(() => {
if(scrollingDiv.scrollTop > 0) {
// need to go further
return backPaginate();
}
console.log("paginated to start.");
// hopefully, we got to the start of the timeline
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
// direction.
setScrollTop(scrollingDiv.scrollHeight);
scrollingDiv.scrollTop = scrollingDiv.scrollHeight;
return awaitScroll();
}).then(() => {

View File

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