diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..5008ddfcf5 Binary files /dev/null and b/.DS_Store differ diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000000..3f6c8e6953 --- /dev/null +++ b/.eslintrc @@ -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 + } + } +} diff --git a/.gitignore b/.gitignore index 8fdaf5903f..5139d614ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +npm-debug.log + /node_modules /lib diff --git a/README.md b/README.md index ae1cd17c9a..dfc1a6e6ec 100644 --- a/README.md +++ b/README.md @@ -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. + diff --git a/code_style.md b/code_style.md new file mode 100644 index 0000000000..7b272e0656 --- /dev/null +++ b/code_style.md @@ -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 + // Bad + {doStuff();}}> // Equally bad + // Better + // 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? diff --git a/jenkins.sh b/jenkins.sh index eeb7d7d56e..b318b586e2 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -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 diff --git a/karma.conf.js b/karma.conf.js index 1ae2494add..45f5dd9998 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -171,5 +171,6 @@ module.exports = function (config) { }, devtool: 'inline-source-map', }, + browserNoActivityTimeout: 15000, }); }; diff --git a/package.json b/package.json index c639895973..e130f9ed71 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/AddThreepid.js b/src/AddThreepid.js index 31805aad11..5593d46ff7 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.js @@ -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; diff --git a/src/CallHandler.js b/src/CallHandler.js index c459d12e31..9118ee1973 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -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; diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 56e3499eae..fd18b22d30 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -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(); diff --git a/src/GuestAccess.js b/src/GuestAccess.js deleted file mode 100644 index ef48d23ded..0000000000 --- a/src/GuestAccess.js +++ /dev/null @@ -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; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index dbcb59a20a..2ab635081f 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -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. foobar 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 ; - } 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. foobar 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 ; }, highlightDom: function(element) { @@ -228,5 +238,11 @@ module.exports = { } }, -} + emojifyText: function(text) { + emojione.imageType = 'svg'; + return { + __html: emojione.unicodeToImage(escape(text)), + }; + }, +}; diff --git a/src/Lifecycle.js b/src/Lifecycle.js new file mode 100644 index 0000000000..7c507c2c50 --- /dev/null +++ b/src/Lifecycle.js @@ -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 +}; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 143b804228..e6d0e7f3f7 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -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; diff --git a/src/MatrixTools.js b/src/MatrixTools.js index 372f17f69c..b003d8d2d7 100644 --- a/src/MatrixTools.js +++ b/src/MatrixTools.js @@ -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; - } } diff --git a/src/PasswordReset.js b/src/PasswordReset.js index bbafa0ef33..a03a565459 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -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; diff --git a/src/RichText.js b/src/RichText.js index 7e749bc24a..7cd78a14c9 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -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
by default which we don't really like, so we're using this is probably not a good idea since 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('')}`; // dirty hack because we don't want block level tags by default, but breaks - if(elem === 'span') + if (elem === 'span') result += '
'; 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 ({props.children}); + }, +}; + /** * 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 ? : null; - return {avatar} {props.children}; + return {avatar}{props.children}; } }; + 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 { ) }); + 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): {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): 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; +} diff --git a/src/Signup.js b/src/Signup.js index 4518955d95..5aadd94701 100644 --- a/src/Signup.js +++ b/src/Signup.js @@ -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; + }); } } } diff --git a/src/SignupStages.js b/src/SignupStages.js index 1c5c48ddd6..2b0d163a08 100644 --- a/src/SignupStages.js +++ b/src/SignupStages.js @@ -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'; } diff --git a/src/SlashCommands.js b/src/SlashCommands.js index e4c0d5973a..759a95c8ff 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -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", "", 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", "", function(room_id, args) { if (args) { @@ -132,46 +112,25 @@ var commands = { }), // Join a room - join: new Command("join", "", 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()); diff --git a/src/TabComplete.js b/src/TabComplete.js index 7da8bde76b..65441c9381 100644 --- a/src/TabComplete.js +++ b/src/TabComplete.js @@ -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; diff --git a/src/TabCompleteEntries.js b/src/TabCompleteEntries.js index a23050063f..4a28103210 100644 --- a/src/TabCompleteEntries.js +++ b/src/TabCompleteEntries.js @@ -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); }); } diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 305994aa0e..f4eb4f0d83 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -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'; }, diff --git a/src/Velociraptor.js b/src/Velociraptor.js index f45925867f..d9b6b3d5dc 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -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 diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js new file mode 100644 index 0000000000..41d5d035d1 --- /dev/null +++ b/src/autocomplete/AutocompleteProvider.js @@ -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 { + 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'; + } +} diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js new file mode 100644 index 0000000000..7f32e0ca40 --- /dev/null +++ b/src/autocomplete/Autocompleter.js @@ -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, + }; + }); +} diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js new file mode 100644 index 0000000000..19a366ac63 --- /dev/null +++ b/src/autocomplete/CommandProvider.js @@ -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: '', + description: 'Displays action', + }, + { + command: '/ban', + args: ' [reason]', + description: 'Bans user with given id', + }, + { + command: '/deop', + args: '', + description: 'Deops user with given id', + }, + { + command: '/invite', + args: '', + description: 'Invites user with given id to current room' + }, + { + command: '/join', + args: '', + description: 'Joins room with given alias', + }, + { + command: '/kick', + args: ' [reason]', + description: 'Kicks user with given id', + }, + { + command: '/nick', + args: '', + 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: (), + range, + }; + }); + } + return Q.when(completions); + } + + getName() { + return 'Commands'; + } + + static getInstance(): CommandProvider { + if (instance == null) + instance = new CommandProvider(); + + return instance; + } +} diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js new file mode 100644 index 0000000000..168da00c1c --- /dev/null +++ b/src/autocomplete/Components.js @@ -0,0 +1,19 @@ +import React from 'react'; + +export function TextualCompletion({ + title, + subtitle, + description, +}: { + title: ?string, + subtitle: ?string, + description: ?string +}) { + return ( +
+ {title} + {subtitle} + {description} +
+ ); +} diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js new file mode 100644 index 0000000000..1746ce0aaa --- /dev/null +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -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: ( + + ), + range, + }; + }); + if (json.Answer) { + results.unshift({ + completion: json.Answer, + component: ( + + ), + range, + }); + } + if (json.RelatedTopics && json.RelatedTopics.length > 0) { + results.unshift({ + completion: json.RelatedTopics[0].Text, + component: ( + + ), + range, + }); + } + if (json.AbstractText) { + results.unshift({ + completion: json.AbstractText, + component: ( + + ), + range, + }); + } + return results; + }); + } + + getName() { + return 'Results from DuckDuckGo'; + } + + static getInstance(): DuckDuckGoProvider { + if (instance == null) { + instance = new DuckDuckGoProvider(); + } + return instance; + } +} diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js new file mode 100644 index 0000000000..574144e95b --- /dev/null +++ b/src/autocomplete/EmojiProvider.js @@ -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: ( +
+ {shortname} +
+ ), + range, + }; + }).slice(0, 4); + } + return Q.when(completions); + } + + getName() { + return 'Emoji'; + } + + static getInstance() { + if (instance == null) + instance = new EmojiProvider(); + return instance; + } +} diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js new file mode 100644 index 0000000000..b34fdeb59a --- /dev/null +++ b/src/autocomplete/RoomProvider.js @@ -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: ( + + ), + range, + }; + }).slice(0, 4); + } + return Q.when(completions); + } + + getName() { + return 'Rooms'; + } + + static getInstance() { + if (instance == null) { + instance = new RoomProvider(); + } + + return instance; + } +} diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js new file mode 100644 index 0000000000..8828f8cb70 --- /dev/null +++ b/src/autocomplete/UserProvider.js @@ -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: ( + + ), + 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; + } +} diff --git a/src/component-index.js b/src/component-index.js index 4aa0efe21f..ca8887858c 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -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'); diff --git a/src/ContextualMenu.js b/src/components/structures/ContextualMenu.js similarity index 63% rename from src/ContextualMenu.js rename to src/components/structures/ContextualMenu.js index e720b69eda..fcfc5d5e50 100644 --- a/src/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -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 = - position.left = props.left + 8; + chevron =
+ position.left = props.left; } else { - chevron = - position.right = props.right + 8; + chevron =
+ 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 = ( -
-
+
+
{chevron}
diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js index f6e2baeaf2..e7585e3640 100644 --- a/src/components/structures/CreateRoom.js +++ b/src/components/structures/CreateRoom.js @@ -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({ diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 08ef4cab9a..c9169e0685 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -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({ ); } else if (this.state.screen == 'forgot_password') { return ( ); } diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index c8e878118b..53efac6406 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -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}/> diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 92f50dcb02..9a0d3dbbdd 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -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 (
- + ); - }, + }, }); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index e1b4c00175..9fbdb51f11 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -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 (
@@ -1346,12 +1428,10 @@ module.exports = React.createClass({ statusBar = } else if (!this.state.searchResults) { var RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); - var tabEntries = this.tabComplete.isTabCompleting() ? - this.tabComplete.peek(6) : null; statusBar = ); diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 63c78d2a3c..9a4c614720 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -540,7 +540,6 @@ module.exports = React.createClass({ // it's not obvious why we have a separate div and ol anyway. return (
    diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index d804dfd6b9..798ee03e73 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -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 } diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 7fcb81a60c..6555668ff4 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -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 ( +
    +

    User Interface

    +
    +
    + UserSettingsStore.setUrlPreviewsDisabled(e.target.checked) } + /> + +
    +
    + { settingsLabels.forEach( setting => { +
    + UserSettingsStore.setSyncedSetting(setting.id, e.target.checked) } + /> + +
    + })} +
    + ); + }, + + _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 ( +
    +

    Devices

    + +
    + ); + }, + + _renderLabs: function () { + let features = LABS_FEATURES.map(feature => ( +
    + { + UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked); + this.forceUpdate(); + }}/> + +
    + )); + return ( +
    +

    Labs

    +
    +

    These are experimental features that may break in unexpected ways. Use with caution.

    + {features} +
    +
    + ) + }, + 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({
); } - this._renderLabs = function () { - let features = LABS_FEATURES.map(feature => ( -
- UserSettingsStore.setFeatureEnabled(feature.id, e.target.checked)} /> - -
- )); - return ( -
-

Labs

- -
-

These are experimental features that may break in unexpected ways. Use with caution.

- {features} -
-
- ) - }; - return (

Profile

@@ -452,9 +525,10 @@ module.exports = React.createClass({ {notification_area} - {this._renderDeviceInfo()} - + {this._renderUserInterfaceSettings()} {this._renderLabs()} + {this._renderDevicesPanel()} + {this._renderCryptoInfo()}

Advanced

diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index aa0c42dc98..a73ad30f87 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -232,7 +232,9 @@ module.exports = React.createClass({displayName: 'Login',
-

Sign in

+

Sign in + { loader } +

{ this.componentForStep(this._getCurrentFlowStep()) }
- { loader } { this.state.errorText }
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index 2f15a3b5df..423d62933f 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -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 = ( diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 121540a8c0..f0a36c6608 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -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 ( - + + alt="" title={title} onError={this.onError} + width={width} height={height} /> ); } return ( + width={width} height={height} + title={title} alt="" + {...otherProps} /> ); } }); diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 5e2dbbb23a..654f801afc 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -59,9 +59,12 @@ module.exports = React.createClass({ render: function() { var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + + var {member, ...otherProps} = this.props; + return ( - + ); } }); diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index 129c68ff1b..dcb25eff61 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -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 ( - ); } diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index b3278dfcfe..cc400e30a6 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -59,7 +59,7 @@ module.exports = React.createClass({ {this.props.description}
-
diff --git a/src/components/views/dialogs/LogoutPrompt.js b/src/components/views/dialogs/LogoutPrompt.js index 67fedfe840..7c4ba18e82 100644 --- a/src/components/views/dialogs/LogoutPrompt.js +++ b/src/components/views/dialogs/LogoutPrompt.js @@ -46,7 +46,7 @@ module.exports = React.createClass({ Sign out?
- +
diff --git a/src/components/views/dialogs/NeedToRegisterDialog.js b/src/components/views/dialogs/NeedToRegisterDialog.js index d9133ee138..0080e0c643 100644 --- a/src/components/views/dialogs/NeedToRegisterDialog.js +++ b/src/components/views/dialogs/NeedToRegisterDialog.js @@ -63,7 +63,7 @@ module.exports = React.createClass({ {this.props.description}
-
- diff --git a/src/components/views/dialogs/SetDisplayNameDialog.js b/src/components/views/dialogs/SetDisplayNameDialog.js index 81ceb21696..c1041cc218 100644 --- a/src/components/views/dialogs/SetDisplayNameDialog.js +++ b/src/components/views/dialogs/SetDisplayNameDialog.js @@ -76,7 +76,7 @@ module.exports = React.createClass({ />
- +
diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index fed7ff079a..6245b5786f 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -86,7 +86,7 @@ module.exports = React.createClass({ -
diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 9218fe820e..15118f249e 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -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); diff --git a/src/components/views/elements/EditableTextContainer.js b/src/components/views/elements/EditableTextContainer.js new file mode 100644 index 0000000000..b17f1b417d --- /dev/null +++ b/src/components/views/elements/EditableTextContainer.js @@ -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 ( + + ); + } else if (this.state.errorString) { + return ( +
{this.state.errorString}
+ ); + } else { + var EditableText = sdk.getComponent('elements.EditableText'); + return ( + + ); + } + } + +} + +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(); }, +}; diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index 3c65ca707c..993f2b965a 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -34,10 +34,15 @@ module.exports = React.createClass({ propTypes: { value: React.PropTypes.number.isRequired, + // if true, the ); @@ -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}

- { this.props.defaultUsername ? + { this.props.guestUsername ?
Setting a user name will create a fresh account
: null } + placeholder="Password" defaultValue={this.props.defaultPassword} />
+ defaultValue={this.props.defaultPassword} />
{registerButton} diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js index 6cbaf0b151..c8327a71ae 100644 --- a/src/components/views/messages/MVideoBody.js +++ b/src/components/views/messages/MVideoBody.js @@ -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; diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 35eafbff22..0863fe0842 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -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 ; }, }); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 310da598fa..8c6cf455dc 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -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 =
{ body }; + } 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 - {TextForEvent.textForEvent(this.props.mxEvent)} +
); }, diff --git a/src/components/views/room_settings/AliasSettings.js b/src/components/views/room_settings/AliasSettings.js index 34b6083c30..8c8ae659e8 100644 --- a/src/components/views/room_settings/AliasSettings.js +++ b/src/components/views/room_settings/AliasSettings.js @@ -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({
Add -
+
: "" }
diff --git a/src/components/views/room_settings/ColorSettings.js b/src/components/views/room_settings/ColorSettings.js index fff97ea817..6d147b1f63 100644 --- a/src/components/views/room_settings/ColorSettings.js +++ b/src/components/views/room_settings/ColorSettings.js @@ -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; diff --git a/src/components/views/room_settings/UrlPreviewSettings.js b/src/components/views/room_settings/UrlPreviewSettings.js new file mode 100644 index 0000000000..e82c2ba201 --- /dev/null +++ b/src/components/views/room_settings/UrlPreviewSettings.js @@ -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 = + + } + else { + disableRoomPreviewUrls = + + } + + return ( +
+

URL Previews

+ + + { disableRoomPreviewUrls } + + +
+ ); + + } +}); \ No newline at end of file diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js new file mode 100644 index 0000000000..32e568e2ba --- /dev/null +++ b/src/components/views/rooms/Autocomplete.js @@ -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 ( +
+ {completion.component} +
+ ); + }); + + + return completions.length > 0 ? ( +
+ {completionResult.provider.getName()} + + {completions} + +
+ ) : null; + }); + + return ( +
+ + {renderedCompletions} + +
+ ); + } +} + +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, +}; diff --git a/src/components/views/rooms/EntityTile.js b/src/components/views/rooms/EntityTile.js index acc424b098..8a99b4c565 100644 --- a/src/components/views/rooms/EntityTile.js +++ b/src/components/views/rooms/EntityTile.js @@ -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 = (
-
{ this.props.name }
+
@@ -101,8 +123,7 @@ module.exports = React.createClass({ } else { nameEl = ( -
- { this.props.name } +
); } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index ff02139d87..7945debd1a 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -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({
diff --git a/src/components/views/rooms/InviteMemberList.js b/src/components/views/rooms/InviteMemberList.js index 5246e2e54d..4742597229 100644 --- a/src/components/views/rooms/InviteMemberList.js +++ b/src/components/views/rooms/InviteMemberList.js @@ -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); }, diff --git a/src/components/views/rooms/MemberDeviceInfo.js b/src/components/views/rooms/MemberDeviceInfo.js index ebc2ab1c32..7e684c89a2 100644 --- a/src/components/views/rooms/MemberDeviceInfo.js +++ b/src/components/views/rooms/MemberDeviceInfo.js @@ -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 = ( -
+ var indicator = null, blockButton = null, verifyButton = null; + if (this.props.device.blocked) { + blockButton = ( +
+ Unblock +
); - button = ( + } else { + blockButton = ( +
+ Block +
+ ); + } + + if (this.props.device.verified) { + verifyButton = (
Unverify
); } else { - button = ( + verifyButton = (
Verify
); } + + if (this.props.device.blocked) { + indicator = ( +
+ ); + } else if (this.props.device.verified) { + indicator = ( +
+ ); + + } else { + indicator = ( +
?
+ ); + } + + var deviceName = this.props.device.display_name || this.props.device.id; + return (
-
{this.props.device.id}
-
{this.props.device.key}
+
{deviceName}
{indicator} - {button} + {verifyButton} + {blockButton}
); }, diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 97cfecc9e1..c087e7dc71 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -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 (

Devices

- {devComponents} +
+ {devComponents} +
); }, @@ -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 = + + 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 = } if (this.state.updating) { @@ -594,6 +637,8 @@ module.exports = React.createClass({
} + 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({
-

{ this.props.member.name }

+

diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 328f9774c7..5536aeddd6 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -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 = (

Invited

-
+
{invitedMemberTiles}
@@ -544,7 +521,6 @@ module.exports = React.createClass({
{inviteMemberListSection} diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index eaee8205e4..edd8ed7b9a 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -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[i].name} ); @@ -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 =
- -
+ +
; videoCallButton =
- -
+ +
; } var canSendMessages = this.props.room.currentState.maySendMessage( @@ -172,7 +199,7 @@ module.exports = React.createClass({ var uploadButton = (
- + , + 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 (
+
+ +
{controls} @@ -206,5 +248,24 @@ module.exports = React.createClass({
); } -}); +}; +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 +}; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 8a0ee7d8a8..2d42b65246 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -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

-function mdownToHtml(mdown) { - var html = marked(mdown) || ""; +function mdownToHtml(mdown: string): string { + let html = marked(mdown) || ""; html = html.trim(); // strip start and end

tags else you get 'orrible spacing if (html.indexOf("

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

); @@ -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, }; diff --git a/src/components/views/rooms/ReadReceiptMarker.js b/src/components/views/rooms/ReadReceiptMarker.js index d40b2f5f8d..91ba201683 100644 --- a/src/components/views/rooms/ReadReceiptMarker.js +++ b/src/components/views/rooms/ReadReceiptMarker.js @@ -163,13 +163,13 @@ module.exports = React.createClass({ }; return ( - + diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 448a46b84f..8c1b2aaff8 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -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 =
-
{ roomName }
+
{ searchStatus } -
- -
} @@ -263,10 +263,18 @@ module.exports = React.createClass({ ); } + var settings_button; + if (this.props.onSettingsClick) { + settings_button = +
+ +
; + } + var leave_button; if (this.props.onLeaveClick) { leave_button = -
+
; } @@ -274,7 +282,7 @@ module.exports = React.createClass({ var forget_button; if (this.props.onForgetClick) { forget_button = -
+
; } @@ -288,10 +296,11 @@ module.exports = React.createClass({ if (!this.props.editing) { right_row =
+ { settings_button } { forget_button } { leave_button }
- +
{ rightPanel_buttons }
; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index aff03182a1..8e57ceab9b 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -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 (
+
+ { error } +
+
+ ); + } + else { + var name = this.props.room ? this.props.room.name : (this.props.room_alias || ""); name = name ? { name } : "a room"; joinBlock = (
diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 5a6453aaa0..9c13d27f68 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -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 = ( + + ); + } + + return ( +
+

Encryption

+ + {button} +
+ ); + }, + 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 =
Tagged as: { canSetTag ? (tags.map(function(tag, i) { @@ -666,10 +711,6 @@ module.exports = React.createClass({ Members only (since they joined)
-
@@ -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')} /> + +

Permissions

@@ -737,6 +780,8 @@ module.exports = React.createClass({ { bannedUsersSection } + { this._renderEncryptionSection() } +

Advanced

This room's internal ID is { this.props.room.roomId } diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 55971cdd60..602ed4ee04 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -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 =
{ notificationCount ? notificationCount : '!' }
; + 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 =
!
; + + if (this.state.areNotifsMuted && !(this.state.badgeHover || this.state.menu)) { + badge =
; + } else { + badge =
{ badgeContent }
; } - else if (this.props.unread) { - badge =
1
; - } - var nameCell; - if (badge) { - nameCell =
{name}
{badge}
; - } - else { - nameCell =
{name}
; - } - */ 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 = { name }; + let nameSelected = ; + + label =
{ nameSelected }
; + } else { + label =
; } - label =
{ name }
; } 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( -
-
- +
+
+ +
+
+ { label } + { badge }
- { label } - { badge } { incomingCallBox } + { tooltip }
)); } diff --git a/src/components/views/rooms/SearchableEntityList.js b/src/components/views/rooms/SearchableEntityList.js index a22126025c..9eeb7b0931 100644 --- a/src/components/views/rooms/SearchableEntityList.js +++ b/src/components/views/rooms/SearchableEntityList.js @@ -179,7 +179,6 @@ var SearchableEntityList = React.createClass({ } list = ( { list } diff --git a/src/components/views/rooms/TabCompleteBar.js b/src/components/views/rooms/TabCompleteBar.js index ea74706f29..d00c0953f1 100644 --- a/src/components/views/rooms/TabCompleteBar.js +++ b/src/components/views/rooms/TabCompleteBar.js @@ -24,17 +24,17 @@ module.exports = React.createClass({ displayName: 'TabCompleteBar', propTypes: { - entries: React.PropTypes.array.isRequired + tabComplete: React.PropTypes.object.isRequired }, render: function() { return (
- {this.props.entries.map(function(entry, i) { + {this.props.tabComplete.peek(6).map((entry, i) => { return (
+ className={ "mx_TabCompleteBar_item " + (entry instanceof CommandEntry ? "mx_TabCompleteBar_command" : "") } + onClick={this.props.tabComplete.onEntryClick.bind(this.props.tabComplete, entry)} > {entry.getImageJsx()} {entry.getText()} diff --git a/src/components/views/settings/ChangeDisplayName.js b/src/components/views/settings/ChangeDisplayName.js index 799a8b9634..26b6c2f830 100644 --- a/src/components/views/settings/ChangeDisplayName.js +++ b/src/components/views/settings/ChangeDisplayName.js @@ -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 ( - - ); - } else if (this.state.errorString) { - return ( -
{this.state.errorString}
- ); - } else { - var EditableText = sdk.getComponent('elements.EditableText'); - return ( - - ); - } + var EditableTextContainer = sdk.getComponent('elements.EditableTextContainer'); + return ( + + ); } }); diff --git a/src/components/views/settings/DevicesPanel.js b/src/components/views/settings/DevicesPanel.js new file mode 100644 index 0000000000..8dd6bb9230 --- /dev/null +++ b/src/components/views/settings/DevicesPanel.js @@ -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 ( + {this._onDeviceDeleted(device)}} /> + ); + } + + render() { + const Spinner = sdk.getComponent("elements.Spinner"); + + if (this.state.deviceLoadError !== undefined) { + const classes = classNames(this.props.className, "error"); + return ( +
+ {this.state.deviceLoadError} +
+ ); + } + + const devices = this.state.devices; + if (devices === undefined) { + // still loading + const classes = this.props.className; + return ; + } + + devices.sort(this._deviceCompare); + + const classes = classNames(this.props.className, "mx_DevicesPanel"); + return ( +
+
+
Name
+
Last seen
+
+
+ {devices.map(this._renderDevice)} +
+ ); + } +} diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js new file mode 100644 index 0000000000..b660f196c8 --- /dev/null +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -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 ( +
+ +
+ ); + } + + 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 =
{this.state.deleteError}
+ } else { + deleteButton = ( +
+ Delete +
+ ); + } + + return ( +
+
+ +
+
+ {lastSeen} +
+
+ {deleteButton} +
+
+ ); + } +} + +DevicesPanelEntry.propTypes = { + device: React.PropTypes.object.isRequired, + onDeleted: React.PropTypes.func, +}; + +DevicesPanelEntry.defaultProps = { + onDeleted: function() {}, +}; diff --git a/src/createRoom.js b/src/createRoom.js index 658561e78a..51e3ff8a96 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -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 diff --git a/src/encryption.js b/src/encryption.js deleted file mode 100644 index cbe92d36de..0000000000 --- a/src/encryption.js +++ /dev/null @@ -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, -} diff --git a/src/ratelimitedfunc.js b/src/ratelimitedfunc.js index 453669b477..ed892f4eac 100644 --- a/src/ratelimitedfunc.js +++ b/src/ratelimitedfunc.js @@ -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; diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index c201c647c6..7a603d138f 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -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( - {scrollDefer.resolve()}} />, + {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(() => { diff --git a/test/test-utils.js b/test/test-utils.js index fc3aaace9f..e2ff5e8c10 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -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]); }