diff --git a/.gitignore b/.gitignore index 1917768116..491fc35975 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ node_modules -build -bundle.css -bundle.js +lib diff --git a/.npmignore b/.npmignore index 1ce5400d43..598224712f 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,3 @@ example examples -build/.module-cache +.module-cache diff --git a/README.md b/README.md index 499dbee4a5..2b38265713 100644 --- a/README.md +++ b/README.md @@ -2,58 +2,29 @@ matrix-react-sdk ================ This is a react-based SDK for inserting a Matrix chat/voip client into a web page. -It provides reusable and customisable UI components backed by the matrix-js-sdk. -Getting started with the trivial example -======================================== +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 + * Zero or more 'modules' containing non-UI functionality -1. Install or update `node.js` so that your `npm` is at least at version `2.0.0` -2. Clone the repo: `git clone https://github.com/matrix-org/matrix-react-sdk.git` -3. Switch to the SDK directory: `cd matrix-react-sdk` -4. Install the prerequisites: `npm install` -5. Switch to the example directory: `cd examples/trivial` -6. Install the example app prerequisites: `npm install` -7. Build the example and start a server: `npm start` +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. -Now open http://127.0.0.1:8080/ in your browser to see your newly built -Matrix client. +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. -Using the example app for development -===================================== - -To work on the CSS and Javascript and have the bundle files update as you -change the source files, you'll need to do two extra things: - -1. Link the react sdk package into the example: - `cd matrix-react-sdk/examples/trivial; npm link ../../` -2. Start a watcher for the CSS files: - `cd matrix-react-sdk; npm run start:css` - -Note that you may need to restart the CSS builder if you add a new file. Note -that `npm start` builds debug versions of the javascript and CSS, which are -much larger than the production versions build by the `npm run build` commands. - -IMPORTANT: If you customise components in your application (and hence require -react from your app) you must be sure to: - -1. Make your app depend on react directly -2. If you `npm link` matrix-react-sdk, manually remove the 'react' directory - from matrix-react-sdk's `node_modules` folder, otherwise browserify will - pull in both copies of react which causes the app to break. +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. How to customise the SDK ======================== -The matrix-react-sdk provides well-defined reusable UI components which may be -customised/replaced by the developer to build into an app. A set of consistent -UI components (View + CSS classes) is called a 'skin' - currently the SDK -provides a very vanilla whitelabelled 'base skin'. In future the SDK could -provide alternative skins (probably by extending the base skin) that provide more -specific look and feels (e.g. "IRC-style", "Skype-style") etc. However, unlike -Wordpress themes and similar, we don't normally expect app developers to define -reusable skins. Instead you just go and incorporate your view customisations -into your actual app. - The SDK uses 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. @@ -131,18 +102,41 @@ components to embed a Matrix client into your app: * 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, the ComponentBroker and matrix-react-sdk and a call to Render - the root React element as in the examples. - * Create React classes for any custom components you wish to add. These - can be based off the files in `views` in the `matrix-react-sdk` package, - modifying the require() statement appropriately. - You only need to copy files you want to customise. - * Add a ComponentBroker.set() call for each of your custom components. These - must come *before* `require("matrix-react-sdk")`. - * Add a way to build your project: we suggest copying the browserify calls - from the example projects, but you could use grunt or gulp. - * Create an index.html file pulling in your compiled index.js file, the - CSS bundle from matrix-react-sdk. + 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. -For more specific detail on any of these steps, look at the `custom` example in -matrix-react-sdk/examples. +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 +from the skins it is based on will not be automatically included. + +To make a skin, create React classes for any custom components you wish to add +in a skin within `src/skins/<skin name>`. These can be based off the files in +`views` in the `matrix-react-skin` package, modifying the require() statement +appropriately. + +If you make a derived skin, you only need copy the files you wish to customise. + +Once you've made all your view files, you need to make a `skinfo.json`. This +contains all the metadata for a skin. This is a JSON file with, currently, a +single key, 'baseSkin'. Set this to the empty string if your skin is a base skin, +or for a derived skin, set it to the path of your base skin's skinfo.json file, as +you would use in a require call. + +Now you have the basis of a skin, you need to generate a skindex.json file. The +`reskindex.js` tool in matrix-react-sdk does this for you. It is suggested that +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. diff --git a/examples/custom/CustomMTextTile.js b/examples/custom/CustomMTextTile.js deleted file mode 100644 index e58ed4c1e6..0000000000 --- a/examples/custom/CustomMTextTile.js +++ /dev/null @@ -1,40 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MTextTileController = require("matrix-react-sdk/src/controllers/molecules/MTextTile"); - -module.exports = React.createClass({ - displayName: 'MTextTile', - mixins: [MTextTileController], - - render: function() { - var content = this.props.mxEvent.getContent(); - return ( - <span ref="content" className="mx_MTextTile mx_MessageTile_content" onClick={this.onClick}> - {content.body} - </span> - ); - }, - - onClick: function(ev) { - global.alert(this.props.mxEvent.getContent().body); - } -}); - diff --git a/examples/custom/README.md b/examples/custom/README.md deleted file mode 100644 index 8125053ce0..0000000000 --- a/examples/custom/README.md +++ /dev/null @@ -1,4 +0,0 @@ -matrix-react-example -==================== - -An example of how to use the Matrix React SDK to build a more customised app diff --git a/examples/custom/index.html b/examples/custom/index.html deleted file mode 100644 index 04c1645c8a..0000000000 --- a/examples/custom/index.html +++ /dev/null @@ -1,12 +0,0 @@ -<!doctype html> -<html lang="en" style="height: 100%; overflow: hidden"> - <head> - <meta charset="utf-8"> - <title>Matrix React SDK Custom Example</title> - </head> - <body style="height: 100%; "> - <section id="matrixchat" style="height: 100%; "></section> - <script src="bundle.js"></script> - <link rel="stylesheet" href="node_modules/matrix-react-sdk/bundle.css"> - </body> -</html> diff --git a/examples/custom/index.js b/examples/custom/index.js deleted file mode 100644 index 66602a0ada..0000000000 --- a/examples/custom/index.js +++ /dev/null @@ -1,40 +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. -*/ - -'use strict'; - -// Remember to make your project depend on react directly as soon as -// you add a require('react') to any file in your project. Do not rely -// on react being pulled in via matrix-react-sdk: browserify breaks -// horribly in this situation and can end up pulling in multiple copies -// of react. -var React = require("react"); - -// We pull in the component broker first, separately, as we need to replace -// components before the SDK loads. -var ComponentBroker = require("matrix-react-sdk/src/ComponentBroker"); - -var CustomMTextTile = require('./CustomMTextTile'); - -ComponentBroker.set('molecules/MTextTile', CustomMTextTile); - -var MatrixReactSdk = require("matrix-react-sdk"); -//var MatrixReactSdk = require("../../src/index"); - -React.render( - <MatrixReactSdk.MatrixChat />, - document.getElementById('matrixchat') -); diff --git a/examples/custom/package.json b/examples/custom/package.json deleted file mode 100644 index 6acec803fa..0000000000 --- a/examples/custom/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "matrix-react-example", - "version": "0.0.1", - "description": "Example usage of matrix-react-sdk", - "author": "matrix.org", - "repository": { - "type": "git", - "url": "https://github.com/matrix-org/matrix-react-sdk" - }, - "license": "Apache-2.0", - "devDependencies": { - "browserify": "^10.2.3", - "envify": "^3.4.0", - "http-server": "^0.8.0", - "matrix-react-sdk": "../../", - "npm-css": "^0.2.3", - "parallelshell": "^1.2.0", - "reactify": "^1.1.1", - "uglify-js": "^2.4.23", - "watchify": "^3.2.1" - }, - "scripts": { - "build": "browserify -t [ envify --NODE_ENV production ] -g reactify index.js | uglifyjs -c -m -o bundle.js", - "start": "parallelshell 'watchify -v -d -g reactify index.js -o bundle.js' 'http-server'" - }, - "dependencies": { - "react": "^0.13.3" - } -} diff --git a/examples/trivial/README.md b/examples/trivial/README.md deleted file mode 100644 index ac26627732..0000000000 --- a/examples/trivial/README.md +++ /dev/null @@ -1,4 +0,0 @@ -matrix-react-example -==================== - -A simple example of how to use the Matrix React SDK diff --git a/examples/trivial/index.html b/examples/trivial/index.html deleted file mode 100644 index 4ec5b9093a..0000000000 --- a/examples/trivial/index.html +++ /dev/null @@ -1,12 +0,0 @@ -<!doctype html> -<html lang="en" style="height: 100%; overflow: hidden"> - <head> - <meta charset="utf-8"> - <title>Matrix React SDK Example</title> - </head> - <body style="height: 100%;"> - <section id="matrixchat" style="height: 100%;"></section> - <script src="bundle.js"></script> - <link rel="stylesheet" href="node_modules/matrix-react-sdk/bundle.css"> - </body> -</html> diff --git a/examples/trivial/index.js b/examples/trivial/index.js deleted file mode 100644 index 2be9054954..0000000000 --- a/examples/trivial/index.js +++ /dev/null @@ -1,75 +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. -*/ - -'use strict'; - -var React = require("react"); -// In normal usage of the module: -//var MatrixReactSdk = require("matrix-react-sdk"); -// Or to import the source directly from the file system: -// (This is useful for debugging the SDK as it seems source -// maps cannot pass through two stages). -var MatrixReactSdk = require("../../src/index"); - -// Here, we do some crude URL analysis to allow -// deep-linking. We only support registration -// deep-links in this example. -function routeUrl(location) { - if (location.hash.indexOf('#/register') == 0) { - var hashparts = location.hash.split('?'); - var params = {}; - if (hashparts.length == 2) { - var pairs = hashparts[1].split('&'); - for (var i = 0; i < pairs.length; ++i) { - var parts = pairs[i].split('='); - if (parts.length != 2) continue; - params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]); - } - } - window.matrixChat.showScreen('register', params); - } -} - -var loaded = false; - -window.onload = function() { - routeUrl(window.location); - loaded = true; -} - -// This will be called whenever the SDK changes screens, -// so a web page can update the URL bar appropriately. -var onNewScreen = function(screen) { - if (!loaded) return; - window.location.hash = '#/'+screen; -} - -// We use this to work out what URL the SDK should -// pass through when registering to allow the user to -// click back to the client having registered. -// It's up to us to recognise if we're loaded with -// this URL and tell MatrixClient to resume registration. -var makeRegistrationUrl = function() { - return window.location.protocol + '//' + - window.location.host + - window.location.pathname + - '#/register'; -} - -window.matrixChat = React.render( - <MatrixReactSdk.MatrixChat onNewScreen={onNewScreen} registrationUrl={makeRegistrationUrl()} />, - document.getElementById('matrixchat') -); diff --git a/examples/trivial/package.json b/examples/trivial/package.json deleted file mode 100644 index 40a7150731..0000000000 --- a/examples/trivial/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "matrix-react-example", - "version": "0.0.1", - "description": "Example usage of matrix-react-sdk", - "author": "matrix.org", - "repository": { - "type": "git", - "url": "https://github.com/matrix-org/matrix-react-sdk" - }, - "license": "Apache-2.0", - "devDependencies": { - "browserify": "^10.2.3", - "envify": "^3.4.0", - "http-server": "^0.8.0", - "matrix-react-sdk": "../../", - "parallelshell": "^1.2.0", - "reactify": "^1.1.1", - "uglify-js": "^2.4.23", - "watchify": "^3.2.1" - }, - "scripts": { - "build": "browserify --ignore olm -t [ envify --NODE_ENV production ] -t reactify index.js | uglifyjs -c -m -o bundle.js", - "start": "parallelshell 'watchify --ignore olm -v -d -t reactify index.js -o bundle.js' 'http-server'" - } -} diff --git a/package.json b/package.json index 0287121355..1ccc475feb 100644 --- a/package.json +++ b/package.json @@ -8,33 +8,34 @@ "url": "https://github.com/matrix-org/matrix-react-sdk" }, "license": "Apache-2.0", - "main": "src/index.js", - "style": "bundle.css", + "main": "lib/index.js", + "bin": { + "reskindex": "./reskindex.js" + }, "scripts": { - "build:skins": "jsx skins build/skins", - "build:logic": "jsx src build/src", - "build:js": "npm run build:skins && npm run build:logic", - "start:js": "jsx -w skins/base/views/ build --source-map-inline", - "build:css": "catw 'skins/base/css/**/*.css' -o bundle.css -c uglifycss --no-watch", - "start:css": "catw 'skins/base/css/**/*.css' -o bundle.css -v", - "build": "npm run build:js && npm run build:css", - "start": "parallelshell 'npm run start:js' 'npm run start:css'", + "build": "babel src -d lib --source-maps", + "start": "babel src -w -d lib --source-maps", + "clean": "rimraf lib", "prepublish": "npm run build" }, "dependencies": { "classnames": "^2.1.2", "filesize": "^3.1.2", "flux": "^2.0.3", - "matrix-js-sdk": "0.2.0", + "glob": "^5.0.14", + "linkifyjs": "^2.0.0-beta.4", + "matrix-js-sdk": "^0.2.1", + "optimist": "^0.6.1", "q": "^1.4.1", "react": "^0.13.3", - "react-loader": "^1.4.0", - "linkifyjs": "^2.0.0-beta.4" + "react-loader": "^1.4.0" }, + "//deps": "The loader packages are here because webpack in a project that depends on us needs them in this package's node_modules folder", + "//depsbuglink": "https://github.com/webpack/webpack/issues/1472", "devDependencies": { - "catw": "^1.0.1", - "parallelshell": "^1.1.1", - "react-tools": "^0.13.3", - "uglifycss": "0.0.15" + "babel": "^5.8.23", + "rimraf": "^2.4.3", + "json-loader": "^0.5.3", + "source-map-loader": "^0.1.5" } } diff --git a/reskindex.js b/reskindex.js new file mode 100755 index 0000000000..f8d45493d3 --- /dev/null +++ b/reskindex.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +var fs = require('fs'); +var path = require('path'); +var glob = require('glob'); + +var args = require('optimist').argv; + +var header = args.h || args.header; + +if (args._.length == 0) { + console.log("No skin given"); + process.exit(1); +} + +var skin = args._[0]; + +try { + fs.accessSync(path.join('src', 'skins', skin), fs.F_OK); +} catch (e) { + console.log("Skin "+skin+" not found"); + process.exit(1); +} + +var skinfoFile = path.join('src', 'skins', skin, 'skinfo.json'); + +try { + fs.accessSync(skinfoFile, fs.F_OK); +} catch (e) { + console.log("Skin "+skin+" has no skinfo.json"); + process.exit(1); +} + +try { + fs.accessSync(path.join('src', 'skins', skin, 'views'), fs.F_OK); +} catch (e) { + console.log("Skin "+skin+" has no views directory"); + process.exit(1); +} + +var skindex = path.join('src', 'skins', skin, 'skindex.js'); +var viewsDir = path.join('src', 'skins', skin, 'views'); + +var strm = fs.createWriteStream(skindex); + +if (header) { + strm.write(fs.readFileSync(header)); + strm.write('\n'); +} + +strm.write("/*\n"); +strm.write(" * THIS FILE IS AUTO-GENERATED\n"); +strm.write(" * You can edit it you like, but your changes will be overwritten,\n"); +strm.write(" * so you'd just be trying to swim upstream like a salmon.\n"); +strm.write(" * You are not a salmon.\n"); +strm.write(" */\n\n"); + +var mySkinfo = JSON.parse(fs.readFileSync(skinfoFile, "utf8")); + +strm.write("var skin = {};\n"); +strm.write('\n'); + +var files = glob.sync('**/*.js', {cwd: viewsDir}); +for (var i = 0; i < files.length; ++i) { + var file = files[i].replace('.js', ''); + var module = (file.replace(/\//g, '.')); + + strm.write("skin['"+module+"'] = require('./views/"+file+"');\n"); + strm.uncork(); +} + +strm.write("\n"); + +if (mySkinfo.baseSkin) { + strm.write("module.exports = require('"+mySkinfo.baseSkin+"');"); + strm.write("var extend = require('matrix-react-sdk/lib/extend');\n"); + strm.write("extend(module.exports, skin);\n"); +} else { + strm.write("module.exports = skin;"); +} + +strm.end(); + diff --git a/skins/base/css/atoms/MessageTimestamp.css b/skins/base/css/atoms/MessageTimestamp.css deleted file mode 100644 index 62b3065661..0000000000 --- a/skins/base/css/atoms/MessageTimestamp.css +++ /dev/null @@ -1,20 +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. -*/ - -.mx_MessageTimestamp { - display: table-cell; - white-space: pre; -} diff --git a/skins/base/css/common.css b/skins/base/css/common.css deleted file mode 100644 index 5153f97065..0000000000 --- a/skins/base/css/common.css +++ /dev/null @@ -1,23 +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. -*/ - -body { - font-family: Helvetica, Arial, Sans-Serif; -} - -div.error { - color: red; -} diff --git a/skins/base/css/molecules/MessageComposer.css b/skins/base/css/molecules/MessageComposer.css deleted file mode 100644 index 829e25a938..0000000000 --- a/skins/base/css/molecules/MessageComposer.css +++ /dev/null @@ -1,20 +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. -*/ - -.mx_MessageComposer textarea { - width: 100%; - margin: auto; -} diff --git a/skins/base/css/molecules/ProgressBar.css b/skins/base/css/molecules/ProgressBar.css deleted file mode 100644 index 8b8adc09c1..0000000000 --- a/skins/base/css/molecules/ProgressBar.css +++ /dev/null @@ -1,25 +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. -*/ - -.mx_ProgressBar { - height: 5px; - border: 1px solid black; -} - -.mx_ProgressBar_fill { - height: 100%; - background-color: #000; -} diff --git a/skins/base/css/molecules/RoomHeader.css b/skins/base/css/molecules/RoomHeader.css deleted file mode 100644 index 63d6fc33fb..0000000000 --- a/skins/base/css/molecules/RoomHeader.css +++ /dev/null @@ -1,20 +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. -*/ - -.mx_RoomHeader { - height: 1em; - padding: 0px; -} diff --git a/skins/base/css/molecules/RoomTile.css b/skins/base/css/molecules/RoomTile.css deleted file mode 100644 index 719551cb57..0000000000 --- a/skins/base/css/molecules/RoomTile.css +++ /dev/null @@ -1,47 +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. -*/ - -.mx_RoomTile { - padding: 5px; - cursor: pointer; -} - -.mx_RoomTile.selected { - text-decoration: underline; -} - -.mx_RoomTile_name { -} - -.mx_RoomTile div { - overflow: hidden; - text-overflow: ellipsis; -} - -.mx_RoomTile.unread { - font-weight: bold; -} - -.mx_RoomTile.highlight { - background-color: lime; -} - -.mx_RoomTile.invited { - font-weight: bold; -} - -.mx_RoomTile:hover { -} diff --git a/skins/base/css/molecules/SenderProfile.css b/skins/base/css/molecules/SenderProfile.css deleted file mode 100644 index 549b598458..0000000000 --- a/skins/base/css/molecules/SenderProfile.css +++ /dev/null @@ -1,20 +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. -*/ - -.mx_SenderProfile { - display: table-cell; - padding: 0px 1em 0em 1em; -} diff --git a/skins/base/css/organisms/RoomView.css b/skins/base/css/organisms/RoomView.css deleted file mode 100644 index 0c75f8fad4..0000000000 --- a/skins/base/css/organisms/RoomView.css +++ /dev/null @@ -1,86 +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. -*/ - -.mx_RoomView { - word-wrap: break-word; - position: relative; -} - -.mx_RoomView .mx_RoomHeader { - height: 30px; -} - -.mx_RoomView_roomWrapper { - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - position: absolute; - width: 100%; - top: 32px; - bottom: 0px; -} - -.mx_RoomView_messagePanel { - -webkit-box-ordinal-group: 1; - -moz-box-ordinal-group: 1; - -ms-flex-order: 1; - -webkit-order: 1; - order: 1; - width: 100%; - height: 100%; - /* background-color: #ff0; */ -} - -.mx_RoomView_messageListWrapper { - height: 100%; - overflow-y: scroll; -} - -.mx_RoomView_MessageList { - display: table; -} - -.mx_RoomView_MessageList_ul { - list-style-type: none; -} - -.mx_RoomView_invitePrompt { -} - -.mx_RoomView .mx_MemberList { - -webkit-box-ordinal-group: 2; - -moz-box-ordinal-group: 2; - -ms-flex-order: 2; - -webkit-order: 2; - order: 2; - - /* background-color: #0f0; */ - width: 250px; - overflow-y: scroll; - height: 100%; -} - -.mx_RoomView .mx_MemberList ul { - margin: 0px; - padding: 0px; -} - -.mx_RoomView .mx_MessageComposer { - width: 100%; - bottom: 0px; -} diff --git a/skins/base/css/pages/MatrixChat.css b/skins/base/css/pages/MatrixChat.css deleted file mode 100644 index 7ce88ec7ff..0000000000 --- a/skins/base/css/pages/MatrixChat.css +++ /dev/null @@ -1,89 +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. -*/ - -.mx_MatrixChat { - position: relative; - width: 100%; - height: 100%; -} - -.mx_MatrixChat_chatWrapper { - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - position: absolute; - width: 100%; - top: 0px; - bottom: 42px; -} - -.mx_MatrixChat_leftPanel { - -webkit-box-ordinal-group: 1; - -moz-box-ordinal-group: 1; - -ms-flex-order: 1; - -webkit-order: 1; - order: 1; - - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - flex-direction: column; - -webkit-flex-direction: column; - - /* background-color: #f00; */ - width: 250px; - height: 100%; -} - -.mx_MatrixChat_leftPanel .mx_MatrixToolbar { - -webkit-box-ordinal-group: 1; - -moz-box-ordinal-group: 1; - -ms-flex-order: 1; - -webkit-order: 1; - order: 1; - - width: 100%; - height: 40px; -} - -.mx_MatrixChat_leftPanel .mx_RoomList { - -webkit-box-ordinal-group: 2; - -moz-box-ordinal-group: 2; - -ms-flex-order: 2; - -webkit-order: 2; - order: 2; - - /* background-color: #0ff; */ - width: 100%; - height: 100%; - overflow-y: scroll; -} - -.mx_MatrixChat .mx_RoomView { - -webkit-box-ordinal-group: 2; - -moz-box-ordinal-group: 2; - -ms-flex-order: 2; - -webkit-order: 2; - order: 2; - - /* background-color: #00f; */ - width: 100%; - height: 100%; -} diff --git a/skins/base/css/templates/Login.css b/skins/base/css/templates/Login.css deleted file mode 100644 index 7dbcde1caa..0000000000 --- a/skins/base/css/templates/Login.css +++ /dev/null @@ -1,22 +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. -*/ - -.mx_Login { - width: 600px; - height: 350px; - position: relative; -} - diff --git a/skins/base/views/atoms/EditableText.js b/skins/base/views/atoms/EditableText.js deleted file mode 100644 index a8f55814e7..0000000000 --- a/skins/base/views/atoms/EditableText.js +++ /dev/null @@ -1,68 +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. -*/ - -'use strict'; - -var React = require('react'); - -var EditableTextController = require("../../../../src/controllers/atoms/EditableText"); - -module.exports = React.createClass({ - displayName: 'EditableText', - mixins: [EditableTextController], - - onKeyUp: function(ev) { - if (ev.key == "Enter") { - this.onFinish(ev); - } else if (ev.key == "Escape") { - this.cancelEdit(); - } - }, - - onClickDiv: function() { - this.setState({ - phase: this.Phases.Edit, - }) - }, - - onFocus: function(ev) { - ev.target.setSelectionRange(0, ev.target.value.length); - }, - - onFinish: function(ev) { - this.setValue(ev.target.value); - }, - - render: function() { - var editable_el; - - if (this.state.phase == this.Phases.Display) { - editable_el = <div ref="display_div" onClick={this.onClickDiv}>{this.state.value}</div>; - } else if (this.state.phase == this.Phases.Edit) { - editable_el = ( - <div> - <input type="text" defaultValue={this.state.value} onKeyUp={this.onKeyUp} onFocus={this.onFocus} onBlur={this.onFinish} autoFocus/> - </div> - ); - } - - return ( - <div className="mx_EditableText"> - {editable_el} - </div> - ); - } -}); diff --git a/skins/base/views/atoms/EnableNotificationsButton.js b/skins/base/views/atoms/EnableNotificationsButton.js deleted file mode 100644 index 7caebb76c5..0000000000 --- a/skins/base/views/atoms/EnableNotificationsButton.js +++ /dev/null @@ -1,38 +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. -*/ - -'use strict'; - -var React = require('react'); - -var EnableNotificationsButtonController = require("../../../../src/controllers/atoms/EnableNotificationsButton"); - -module.exports = React.createClass({ - displayName: 'EnableNotificationsButton', - mixins: [EnableNotificationsButtonController], - - render: function() { - if (this.enabled()) { - return ( - <button className="mx_EnableNotificationsButton" onClick={this.onClick}>Disable Notifications</button> - ); - } else { - return ( - <button className="mx_EnableNotificationsButton" onClick={this.onClick}>Enable Notifications</button> - ); - } - } -}); diff --git a/skins/base/views/atoms/LogoutButton.js b/skins/base/views/atoms/LogoutButton.js deleted file mode 100644 index 8cc5b27d5e..0000000000 --- a/skins/base/views/atoms/LogoutButton.js +++ /dev/null @@ -1,32 +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. -*/ - -'use strict'; - -var React = require('react'); - -var LogoutButtonController = require("../../../../src/controllers/atoms/LogoutButton"); - -module.exports = React.createClass({ - displayName: 'LogoutButton', - mixins: [LogoutButtonController], - - render: function() { - return ( - <button className="mx_LogoutButton" onClick={this.onClick}>Sign out</button> - ); - } -}); diff --git a/skins/base/views/atoms/MessageTimestamp.js b/skins/base/views/atoms/MessageTimestamp.js deleted file mode 100644 index 52eb1462eb..0000000000 --- a/skins/base/views/atoms/MessageTimestamp.js +++ /dev/null @@ -1,36 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MessageTimestampController = require("../../../../src/controllers/atoms/MessageTimestamp"); - -module.exports = React.createClass({ - displayName: 'MessageTimestamp', - mixins: [MessageTimestampController], - - render: function() { - var date = new Date(this.props.ts); - return ( - <span className="mx_MessageTimestamp"> - {date.toLocaleTimeString()} - </span> - ); - }, -}); - diff --git a/skins/base/views/atoms/create_room/CreateRoomButton.js b/skins/base/views/atoms/create_room/CreateRoomButton.js deleted file mode 100644 index 2f9ccae030..0000000000 --- a/skins/base/views/atoms/create_room/CreateRoomButton.js +++ /dev/null @@ -1,32 +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. -*/ - -'use strict'; - -var React = require('react'); - -var CreateRoomButtonController = require("../../../../../src/controllers/atoms/create_room/CreateRoomButton"); - -module.exports = React.createClass({ - displayName: 'CreateRoomButton', - mixins: [CreateRoomButtonController], - - render: function() { - return ( - <button className="mx_CreateRoomButton" onClick={this.onClick}>Create Room</button> - ); - } -}); diff --git a/skins/base/views/atoms/create_room/Presets.js b/skins/base/views/atoms/create_room/Presets.js deleted file mode 100644 index 83fe61bdbb..0000000000 --- a/skins/base/views/atoms/create_room/Presets.js +++ /dev/null @@ -1,39 +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. -*/ - -'use strict'; - -var React = require('react'); - -var PresetsController = require("../../../../../src/controllers/atoms/create_room/Presets"); - -module.exports = React.createClass({ - displayName: 'CreateRoomPresets', - mixins: [PresetsController], - - onValueChanged: function(ev) { - this.setState({preset: ev.target.value}) - }, - - render: function() { - return ( - <select className="mx_Presets" onChange={this.onValueChanged} defaultValue={this.state.preset}> - <option value="private_chat">Private Chat</option> - <option value="public_chat">Public Chat</option> - </select> - ); - } -}); diff --git a/skins/base/views/atoms/create_room/RoomNameTextbox.js b/skins/base/views/atoms/create_room/RoomNameTextbox.js deleted file mode 100644 index c358a14cb3..0000000000 --- a/skins/base/views/atoms/create_room/RoomNameTextbox.js +++ /dev/null @@ -1,36 +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. -*/ - -'use strict'; - -var React = require('react'); - -var RoomNameTextboxController = require("../../../../../src/controllers/atoms/create_room/RoomNameTextbox"); - -module.exports = React.createClass({ - displayName: 'RoomNameTextbox', - mixins: [RoomNameTextboxController], - - onValueChanged: function(ev) { - this.setState({room_name: ev.target.value}) - }, - - render: function() { - return ( - <input type="text" className="mx_RoomNameTextbox" placeholder="ex. MyNewRoom" onChange={this.onValueChanged}/> - ); - } -}); diff --git a/skins/base/views/molecules/MEmoteTile.js b/skins/base/views/molecules/MEmoteTile.js deleted file mode 100644 index e1b5045db7..0000000000 --- a/skins/base/views/molecules/MEmoteTile.js +++ /dev/null @@ -1,38 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MEmoteTileController = require("../../../../src/controllers/molecules/MEmoteTile"); - -module.exports = React.createClass({ - displayName: 'MEmoteTile', - mixins: [MEmoteTileController], - - render: function() { - var mxEvent = this.props.mxEvent; - var content = mxEvent.getContent(); - var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); - return ( - <li className="mx_MEmoteTile mx_MessageTile_content"> - * {name} {content.body} - </li> - ); - }, -}); - diff --git a/skins/base/views/molecules/MFileTile.js b/skins/base/views/molecules/MFileTile.js deleted file mode 100644 index 7685fc75e0..0000000000 --- a/skins/base/views/molecules/MFileTile.js +++ /dev/null @@ -1,41 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MFileTileController = require("../../../../src/controllers/molecules/MFileTile"); - -var MatrixClientPeg = require('../../../../src/MatrixClientPeg'); - -module.exports = React.createClass({ - displayName: 'MFileTile', - mixins: [MFileTileController], - - render: function() { - var content = this.props.mxEvent.getContent(); - var cli = MatrixClientPeg.get(); - - return ( - <li className="mx_MFileTile"> - <a href={cli.mxcUrlToHttp(content.url)} target="_blank"> - {this.presentableTextForFile(content)} - </a> - </li> - ); - }, -}); diff --git a/skins/base/views/molecules/MImageTile.js b/skins/base/views/molecules/MImageTile.js deleted file mode 100644 index 97cefc538e..0000000000 --- a/skins/base/views/molecules/MImageTile.js +++ /dev/null @@ -1,69 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MImageTileController = require("../../../../src/controllers/molecules/MImageTile"); - -var MatrixClientPeg = require('../../../../src/MatrixClientPeg'); - -module.exports = React.createClass({ - displayName: 'MImageTile', - mixins: [MImageTileController], - - thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { - if (!fullWidth || !fullHeight) { - // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even - // log this because it's spammy - return undefined; - } - if (fullWidth < thumbWidth && fullHeight < thumbHeight) { - // no scaling needs to be applied - return fullHeight; - } - var widthMulti = thumbWidth / fullWidth; - var heightMulti = thumbHeight / fullHeight; - if (widthMulti < heightMulti) { - // width is the dominant dimension so scaling will be fixed on that - return Math.floor(widthMulti * fullHeight); - } - else { - // height is the dominant dimension so scaling will be fixed on that - return Math.floor(heightMulti * fullHeight); - } - }, - - render: function() { - var content = this.props.mxEvent.getContent(); - var cli = MatrixClientPeg.get(); - - var thumbHeight = null; - if (content.info) thumbHeight = this.thumbHeight(content.info.w, content.info.h, 320, 240); - - var imgStyle = {}; - if (thumbHeight) imgStyle['height'] = thumbHeight; - - return ( - <li className="mx_MImageTile"> - <a href={cli.mxcUrlToHttp(content.url)} target="_blank"> - <img src={cli.mxcUrlToHttp(content.url, 320, 240)} alt={content.body} style={imgStyle} /> - </a> - </li> - ); - }, -}); diff --git a/skins/base/views/molecules/MNoticeTile.js b/skins/base/views/molecules/MNoticeTile.js deleted file mode 100644 index f63a8c2cea..0000000000 --- a/skins/base/views/molecules/MNoticeTile.js +++ /dev/null @@ -1,36 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MNoticeTileController = require("../../../../src/controllers/molecules/MNoticeTile"); - -module.exports = React.createClass({ - displayName: 'MNoticeTile', - mixins: [MNoticeTileController], - - render: function() { - var content = this.props.mxEvent.getContent(); - return ( - <span ref="content" className="mx_MNoticeTile mx_MessageTile_content"> - {content.body} - </span> - ); - }, -}); - diff --git a/skins/base/views/molecules/MRoomMemberTile.js b/skins/base/views/molecules/MRoomMemberTile.js deleted file mode 100644 index f0755e2614..0000000000 --- a/skins/base/views/molecules/MRoomMemberTile.js +++ /dev/null @@ -1,57 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MRoomMemberTileController = require("../../../../src/controllers/molecules/MRoomMemberTile"); - -var ComponentBroker = require('../../../../src/ComponentBroker'); -var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp'); - -module.exports = React.createClass({ - displayName: 'MRoomMemberTile', - mixins: [MRoomMemberTileController], - - getMemberEventText: function() { - var ev = this.props.mxEvent; - // XXX: SYJS-16 - var senderName = ev.sender ? ev.sender.name : "Someone"; - switch (ev.getContent().membership) { - case 'invite': - return senderName + " invited " + ev.target.name + "."; - case 'join': - return senderName + " joined the room."; - case 'leave': - return senderName + " left the room."; - } - }, - - render: function() { - // XXX: for now, just cheekily borrow the css from message tile... - return ( - <div className="mx_MessageTile"> - <MessageTimestamp ts={this.props.mxEvent.getTs()} /> - <span className="mx_SenderProfile"></span> - <span className="mx_MessageTile_content"> - {this.getMemberEventText()} - </span> - </div> - ); - }, -}); - diff --git a/skins/base/views/molecules/MTextTile.js b/skins/base/views/molecules/MTextTile.js deleted file mode 100644 index d08f42ed9a..0000000000 --- a/skins/base/views/molecules/MTextTile.js +++ /dev/null @@ -1,36 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MTextTileController = require("../../../../src/controllers/molecules/MTextTile"); - -module.exports = React.createClass({ - displayName: 'MTextTile', - mixins: [MTextTileController], - - render: function() { - var content = this.props.mxEvent.getContent(); - return ( - <span ref="content" className="mx_MTextTile mx_MessageTile_content"> - {content.body} - </span> - ); - }, -}); - diff --git a/skins/base/views/molecules/MatrixToolbar.js b/skins/base/views/molecules/MatrixToolbar.js deleted file mode 100644 index e4444ee9c8..0000000000 --- a/skins/base/views/molecules/MatrixToolbar.js +++ /dev/null @@ -1,41 +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. -*/ - -'use strict'; - -var React = require('react'); - -var ComponentBroker = require('../../../../src/ComponentBroker'); - -var LogoutButton = ComponentBroker.get("atoms/LogoutButton"); -var EnableNotificationsButton = ComponentBroker.get("atoms/EnableNotificationsButton"); - -var MatrixToolbarController = require("../../../../src/controllers/molecules/MatrixToolbar"); - -module.exports = React.createClass({ - displayName: 'MatrixToolbar', - mixins: [MatrixToolbarController], - - render: function() { - return ( - <div className="mx_MatrixToolbar"> - <LogoutButton /> - <EnableNotificationsButton /> - </div> - ); - } -}); - diff --git a/skins/base/views/molecules/MemberTile.js b/skins/base/views/molecules/MemberTile.js deleted file mode 100644 index 60d1cadd84..0000000000 --- a/skins/base/views/molecules/MemberTile.js +++ /dev/null @@ -1,33 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MemberTileController = require("../../../../src/controllers/molecules/MemberTile"); - -module.exports = React.createClass({ - displayName: 'MemberTile', - mixins: [MemberTileController], - render: function() { - return ( - <div className="mx_MemberTile"> - <div className="mx_MemberTile_name">{this.props.member.name}</div> - </div> - ); - } -}); diff --git a/skins/base/views/molecules/MessageTile.js b/skins/base/views/molecules/MessageTile.js deleted file mode 100644 index b28e562b20..0000000000 --- a/skins/base/views/molecules/MessageTile.js +++ /dev/null @@ -1,66 +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. -*/ - -'use strict'; - -var React = require('react'); - -var classNames = require("classnames"); - -var ComponentBroker = require('../../../../src/ComponentBroker'); - -var MessageTimestamp = ComponentBroker.get('atoms/MessageTimestamp'); -var SenderProfile = ComponentBroker.get('molecules/SenderProfile'); - -var UnknownMessageTile = ComponentBroker.get('molecules/UnknownMessageTile'); - -var tileTypes = { - 'm.text': ComponentBroker.get('molecules/MTextTile'), - 'm.notice': ComponentBroker.get('molecules/MNoticeTile'), - 'm.emote': ComponentBroker.get('molecules/MEmoteTile'), - 'm.image': ComponentBroker.get('molecules/MImageTile'), - 'm.file': ComponentBroker.get('molecules/MFileTile') -}; - -var MessageTileController = require("../../../../src/controllers/molecules/MessageTile"); - -module.exports = React.createClass({ - displayName: 'MessageTile', - mixins: [MessageTileController], - - render: function() { - var content = this.props.mxEvent.getContent(); - var msgtype = content.msgtype; - var TileType = UnknownMessageTile; - if (msgtype && tileTypes[msgtype]) { - TileType = tileTypes[msgtype]; - } - var classes = classNames({ - mx_MessageTile: true, - mx_MessageTile_sending: this.props.mxEvent.status == 'sending', - mx_MessageTile_notSent: this.props.mxEvent.status == 'not_sent', - mx_MessageTile_highlight: this.shouldHighlight() - }); - return ( - <li className={classes}> - <MessageTimestamp ts={this.props.mxEvent.getTs()} /> - <SenderProfile mxEvent={this.props.mxEvent} /> - <TileType mxEvent={this.props.mxEvent} /> - </li> - ); - }, -}); - diff --git a/skins/base/views/molecules/ProgressBar.js b/skins/base/views/molecules/ProgressBar.js deleted file mode 100644 index 0946ffcc26..0000000000 --- a/skins/base/views/molecules/ProgressBar.js +++ /dev/null @@ -1,37 +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. -*/ - -'use strict'; - -var React = require('react'); - -var ProgressBarController = require("../../../../src/controllers/molecules/ProgressBar"); - -module.exports = React.createClass({ - displayName: 'ProgressBar', - mixins: [ProgressBarController], - - render: function() { - // Would use an HTML5 progress tag but if that doesn't animate if you - // use the HTML attributes rather than styles - var progressStyle = { - width: ((this.props.value / this.props.max) * 100)+"%" - }; - return ( - <div className="mx_ProgressBar"><div className="mx_ProgressBar_fill" style={progressStyle}></div></div> - ); - } -}); diff --git a/skins/base/views/molecules/RoomTile.js b/skins/base/views/molecules/RoomTile.js deleted file mode 100644 index 0e80fc2015..0000000000 --- a/skins/base/views/molecules/RoomTile.js +++ /dev/null @@ -1,44 +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. -*/ - -'use strict'; - -var React = require('react'); -var classNames = require('classnames'); - -var RoomTileController = require("../../../../src/controllers/molecules/RoomTile"); - -var MatrixClientPeg = require("../../../../src/MatrixClientPeg"); - -module.exports = React.createClass({ - displayName: 'RoomTile', - mixins: [RoomTileController], - render: function() { - var myUserId = MatrixClientPeg.get().credentials.userId; - var classes = classNames({ - 'mx_RoomTile': true, - 'selected': this.props.selected, - 'unread': this.props.unread, - 'highlight': this.props.highlight, - 'invited': this.props.room.currentState.members[myUserId].membership == 'invite' - }); - return ( - <div className={classes} onClick={this.onClick}> - <div className="mx_RoomTile_name">{this.props.room.name}</div> - </div> - ); - } -}); diff --git a/skins/base/views/molecules/SenderProfile.js b/skins/base/views/molecules/SenderProfile.js deleted file mode 100644 index d71d1c2226..0000000000 --- a/skins/base/views/molecules/SenderProfile.js +++ /dev/null @@ -1,42 +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. -*/ - -'use strict'; - -var React = require('react'); - -var SenderProfileController = require("../../../../src/controllers/molecules/SenderProfile"); - -module.exports = React.createClass({ - displayName: 'SenderProfile', - mixins: [SenderProfileController], - - render: function() { - var mxEvent = this.props.mxEvent; - var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); - - var msgtype = mxEvent.getContent().msgtype; - if (msgtype && msgtype == 'm.emote') { - name = ''; // emote message must include the name so don't duplicate it - } - return ( - <span className="mx_SenderProfile"> - {name} - </span> - ); - }, -}); - diff --git a/skins/base/views/molecules/ServerConfig.js b/skins/base/views/molecules/ServerConfig.js deleted file mode 100644 index e06536c552..0000000000 --- a/skins/base/views/molecules/ServerConfig.js +++ /dev/null @@ -1,43 +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. -*/ - -'use strict'; - -var React = require('react'); - -var ServerConfigController = require("../../../../src/controllers/molecules/ServerConfig"); - -module.exports = React.createClass({ - displayName: 'ServerConfig', - mixins: [ServerConfigController], - - render: function() { - return ( - <div className="HomeServerTextBox"> - <table className="serverConfig"> - <tr> - <td>Home Server URL</td> - <td><input type="text" value={this.state.hs_url} onChange={this.hsChanged} /></td> - </tr> - <tr> - <td>Identity Server URL</td> - <td><input type="text" value={this.state.is_url} onChange={this.isChanged} /></td> - </tr> - </table> - </div> - ); - } -}); diff --git a/skins/base/views/molecules/UnknownMessageTile.js b/skins/base/views/molecules/UnknownMessageTile.js deleted file mode 100644 index 27e801c994..0000000000 --- a/skins/base/views/molecules/UnknownMessageTile.js +++ /dev/null @@ -1,34 +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. -*/ - -'use strict'; - -var React = require('react'); - -var UnknownMessageTileController = require("../../../../src/controllers/molecules/UnknownMessageTile"); - -module.exports = React.createClass({ - displayName: 'UnknownMessageTile', - mixins: [UnknownMessageTileController], - - render: function() { - return ( - <span className="mx_UnknownMessageTile"> - ? - </span> - ); - }, -}); diff --git a/skins/base/views/molecules/UserSelector.js b/skins/base/views/molecules/UserSelector.js deleted file mode 100644 index 7517e29d0f..0000000000 --- a/skins/base/views/molecules/UserSelector.js +++ /dev/null @@ -1,44 +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. -*/ - -'use strict'; - -var React = require('react'); - -var UserSelectorController = require("../../../../src/controllers/molecules/UserSelector"); - -module.exports = React.createClass({ - displayName: 'UserSelector', - mixins: [UserSelectorController], - - onAddUserId: function() { - this.addUser(this.refs.user_id_input.getDOMNode().value); - }, - - render: function() { - return ( - <div> - <ul className="mx_UserSelector_UserIdList" ref="list"> - {this.state.selected_users.map(function(user_id, i) { - return <li key={user_id}>{user_id}</li> - })} - </ul> - <input type="text" ref="user_id_input" className="mx_UserSelector_userIdInput" placeholder="ex. @bob:example.com"/> - <button onClick={this.onAddUserId} className="mx_UserSelector_AddUserId">Add User</button> - </div> - ); - } -}); diff --git a/skins/base/views/organisms/CreateRoom.js b/skins/base/views/organisms/CreateRoom.js deleted file mode 100644 index 36f6e466e5..0000000000 --- a/skins/base/views/organisms/CreateRoom.js +++ /dev/null @@ -1,73 +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. -*/ - -'use strict'; - -var React = require('react'); - -var CreateRoomController = require("../../../../src/controllers/organisms/CreateRoom"); - -var ComponentBroker = require('../../../../src/ComponentBroker'); - -var CreateRoomButton = ComponentBroker.get("atoms/create_room/CreateRoomButton"); -var RoomNameTextbox = ComponentBroker.get("atoms/create_room/RoomNameTextbox"); -var Presets = ComponentBroker.get("atoms/create_room/Presets"); -var UserSelector = ComponentBroker.get("molecules/UserSelector"); - - -module.exports = React.createClass({ - displayName: 'CreateRoom', - mixins: [CreateRoomController], - - getPreset: function() { - return this.refs.presets.getPreset(); - }, - - getName: function() { - return this.refs.name_textbox.getName(); - }, - - getInvitedUsers: function() { - return this.refs.user_selector.getUserIds(); - }, - - render: function() { - var curr_phase = this.state.phase; - if (curr_phase == this.phases.CREATING) { - return ( - <div>Creating...</div> - ); - } else { - var error_box = ""; - if (curr_phase == this.phases.ERROR) { - error_box = ( - <div className="mx_Error"> - An error occured: {this.state.error_string} - </div> - ); - } - return ( - <div className="mx_CreateRoom"> - <label>Room Name <RoomNameTextbox ref="name_textbox" /></label> - <Presets ref="presets"/> - <UserSelector ref="user_selector"/> - <CreateRoomButton onCreateRoom={this.onCreateRoom} /> - {error_box} - </div> - ); - } - } -}); diff --git a/skins/base/views/organisms/MemberList.js b/skins/base/views/organisms/MemberList.js deleted file mode 100644 index 5d1b2fd0f9..0000000000 --- a/skins/base/views/organisms/MemberList.js +++ /dev/null @@ -1,56 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MemberListController = require("../../../../src/controllers/organisms/MemberList"); - -var ComponentBroker = require('../../../../src/ComponentBroker'); - -var MemberTile = ComponentBroker.get("molecules/MemberTile"); - - -module.exports = React.createClass({ - displayName: 'MemberList', - mixins: [MemberListController], - - makeMemberTiles: function() { - var that = this; - return Object.keys(that.state.memberDict).map(function(userId) { - var m = that.state.memberDict[userId]; - return ( - <li key={userId}> - <MemberTile - member={m} - /> - </li> - ); - }); - }, - - render: function() { - return ( - <div className="mx_MemberList"> - <ul> - {this.makeMemberTiles()} - </ul> - </div> - ); - } -}); - diff --git a/skins/base/views/organisms/Notifier.js b/skins/base/views/organisms/Notifier.js deleted file mode 100644 index 09f1921ac3..0000000000 --- a/skins/base/views/organisms/Notifier.js +++ /dev/null @@ -1,102 +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. -*/ - -'use strict'; - -var NotifierController = require("../../../../src/controllers/organisms/Notifier"); - -var MatrixClientPeg = require("../../../../src/MatrixClientPeg"); -var extend = require("../../../../src/extend"); -var dis = require("../../../../src/dispatcher"); - - -var NotifierView = { - notificationMessageForEvent: function(ev) { - var senderDisplayName = ev.sender ? ev.sender.name : ''; - var message = null; - - if (ev.event.type === "m.room.message") { - message = ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = senderDisplayName + " sent an image."; - } - } else if (ev.event.type == "m.room.member") { - if (ev.event.state_key !== MatrixClientPeg.get().credentials.userId && "join" === ev.getContent().membership) { - // Notify when another user joins - message = senderDisplayName + " joined"; - } else if (ev.event.state_key === MatrixClientPeg.get().credentials.userId && "invite" === ev.getContent().membership) { - // notify when you are invited - message = senderDisplayName + " invited you to a room"; - } - } - return message; - }, - - displayNotification: function(ev, room) { - if (!global.Notification || global.Notification.permission != 'granted') { - return; - } - if (global.document.hasFocus()) { - return; - } - - var msg = this.notificationMessageForEvent(ev); - if (!msg) return; - - var title; - if (!ev.sender || room.name == ev.sender.name) { - title = room.name; - } else if (ev.sender) { - title = ev.sender.name + " (" + room.name + ")"; - } - - var notification = new global.Notification( - title, - { - "body": msg, - "icon": MatrixClientPeg.get().getAvatarUrlForMember(ev.sender) - } - ); - - notification.onclick = function() { - dis.dispatch({ - action: 'view_room', - room_id: room.roomId - }); - global.focus(); - }; - - /*var audioClip; - - if (audioNotification) { - audioClip = playAudio(audioNotification); - }*/ - - global.setTimeout(function() { - notification.close(); - }, 5 * 1000); - - } -}; - -var NotifierClass = function() {}; -extend(NotifierClass.prototype, NotifierController); -extend(NotifierClass.prototype, NotifierView); - -module.exports = new NotifierClass(); - diff --git a/skins/base/views/organisms/RoomList.js b/skins/base/views/organisms/RoomList.js deleted file mode 100644 index f8be66f76e..0000000000 --- a/skins/base/views/organisms/RoomList.js +++ /dev/null @@ -1,36 +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. -*/ - -'use strict'; - -var React = require('react'); - -var RoomListController = require("../../../../src/controllers/organisms/RoomList"); - - -module.exports = React.createClass({ - displayName: 'RoomList', - mixins: [RoomListController], - - render: function() { - return ( - <div className="mx_RoomList"> - {this.makeRoomTiles()} - </div> - ); - } -}); - diff --git a/skins/base/views/organisms/RoomView.js b/skins/base/views/organisms/RoomView.js deleted file mode 100644 index 20d073b990..0000000000 --- a/skins/base/views/organisms/RoomView.js +++ /dev/null @@ -1,99 +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. -*/ - -'use strict'; - -var React = require('react'); - -var MatrixClientPeg = require("../../../../src/MatrixClientPeg"); - -var ComponentBroker = require('../../../../src/ComponentBroker'); -var classNames = require("classnames"); - -var MessageTile = ComponentBroker.get('molecules/MessageTile'); -var RoomHeader = ComponentBroker.get('molecules/RoomHeader'); -var MemberList = ComponentBroker.get('organisms/MemberList'); -var MessageComposer = ComponentBroker.get('molecules/MessageComposer'); - -var RoomViewController = require("../../../../src/controllers/organisms/RoomView"); - -var Loader = require("react-loader"); - - -module.exports = React.createClass({ - displayName: 'RoomView', - mixins: [RoomViewController], - - render: function() { - if (!this.state.room) { - return ( - <div /> - ); - } - - var myUserId = MatrixClientPeg.get().credentials.userId; - if (this.state.room.currentState.members[myUserId].membership == 'invite') { - if (this.state.joining) { - return ( - <div className="mx_RoomView"> - <Loader /> - </div> - ); - } else { - var inviteEvent = this.state.room.currentState.members[myUserId].events.member.event; - // XXX: Leaving this intentionally basic for now because invites are about to change totally - var joinErrorText = this.state.joinError ? "Failed to join room!" : ""; - return ( - <div className="mx_RoomView"> - <div className="mx_RoomView_invitePrompt"> - <div>{inviteEvent.user_id} has invited you to a room</div> - <button ref="joinButton" onClick={this.onJoinButtonClicked}>Join</button> - <div className="error">{joinErrorText}</div> - </div> - </div> - ); - } - } else { - var scrollheader_classes = classNames({ - mx_RoomView_scrollheader: true, - loading: this.state.paginating - }); - return ( - <div className="mx_RoomView"> - <RoomHeader room={this.state.room} /> - <div className="mx_RoomView_roomWrapper"> - <main className="mx_RoomView_messagePanel"> - <div ref="messageWrapper" className="mx_RoomView_messageListWrapper" onScroll={this.onMessageListScroll}> - <div className="mx_RoomView_MessageList"> - <div className={scrollheader_classes}> - </div> - <ul className="mx_RoomView_MessageList_ul" aria-live="polite"> - {this.getEventTiles()} - </ul> - </div> - </div> - <MessageComposer roomId={this.props.roomId} /> - </main> - <aside> - <MemberList roomId={this.props.roomId} key={this.props.roomId} /> - </aside> - </div> - </div> - ); - } - }, -}); - diff --git a/skins/base/views/pages/MatrixChat.js b/skins/base/views/pages/MatrixChat.js deleted file mode 100644 index be4cd43417..0000000000 --- a/skins/base/views/pages/MatrixChat.js +++ /dev/null @@ -1,70 +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. -*/ - -'use strict'; - -var React = require('react'); -var ComponentBroker = require('../../../../src/ComponentBroker'); - -var RoomList = ComponentBroker.get('organisms/RoomList'); -var RoomView = ComponentBroker.get('organisms/RoomView'); -var MatrixToolbar = ComponentBroker.get('molecules/MatrixToolbar'); -var Login = ComponentBroker.get('templates/Login'); -var Register = ComponentBroker.get('templates/Register'); - -var MatrixChatController = require("../../../../src/controllers/pages/MatrixChat"); - -// should be atomised -var Loader = require("react-loader"); - - -module.exports = React.createClass({ - displayName: 'MatrixChat', - mixins: [MatrixChatController], - - render: function() { - if (this.state.logged_in && this.state.ready) { - return ( - <div className="mx_MatrixChat"> - <div className="mx_MatrixChat_chatWrapper"> - <aside className="mx_MatrixChat_leftPanel"> - <RoomList selectedRoom={this.state.currentRoom} /> - <MatrixToolbar /> - </aside> - <RoomView roomId={this.state.currentRoom} key={this.state.currentRoom} /> - </div> - </div> - ); - } else if (this.state.logged_in) { - return ( - <Loader /> - ); - } else if (this.state.screen == 'register') { - return ( - <Register onLoggedIn={this.onLoggedIn} clientSecret={this.state.register_client_secret} - sessionId={this.state.register_session_id} idSid={this.state.register_id_sid} - hsUrl={this.state.register_hs_url} isUrl={this.state.register_is_url} - registrationUrl={this.props.registrationUrl} - /> - ); - } else { - return ( - <Login onLoggedIn={this.onLoggedIn} /> - ); - } - } -}); - diff --git a/skins/base/views/templates/Login.js b/skins/base/views/templates/Login.js deleted file mode 100644 index f71e307068..0000000000 --- a/skins/base/views/templates/Login.js +++ /dev/null @@ -1,102 +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. -*/ - -'use strict'; - -var React = require('react'); - -var ComponentBroker = require("../../../../src/ComponentBroker"); - -var ProgressBar = ComponentBroker.get("molecules/ProgressBar"); -var Loader = require("react-loader"); - -var LoginController = require("../../../../src/controllers/templates/Login"); - -var ServerConfig = ComponentBroker.get("molecules/ServerConfig"); - -module.exports = React.createClass({ - displayName: 'Login', - mixins: [LoginController], - - getHsUrl: function() { - return this.refs.serverConfig.getHsUrl(); - }, - - getIsUrl: function() { - return this.refs.serverConfig.getIsUrl(); - }, - - /** - * Gets the form field values for the current login stage - */ - getFormVals: function() { - return { - 'username': this.refs.user.getDOMNode().value, - 'password': this.refs.pass.getDOMNode().value - }; - }, - - componentForStep: function(step) { - switch (step) { - case 'choose_hs': - return ( - <div> - <form onSubmit={this.onHSChosen}> - <ServerConfig ref="serverConfig" /> - <input type="submit" value="Continue" /> - </form> - </div> - ); - // XXX: clearly these should be separate organisms - case 'stage_m.login.password': - return ( - <div> - <form onSubmit={this.onUserPassEntered}> - <input ref="user" type="text" placeholder="username" /><br /> - <input ref="pass" type="password" placeholder="password" /><br /> - <input type="submit" value="Log in" /> - </form> - </div> - ); - } - }, - - loginContent: function() { - if (this.state.busy) { - return ( - <Loader /> - ); - } else { - return ( - <div> - <h1>Please log in:</h1> - {this.componentForStep(this.state.step)} - <div className="error">{this.state.errorText}</div> - <a onClick={this.showRegister} href="#">Create a new account</a> - </div> - ); - } - }, - - render: function() { - return ( - <div className="mx_Login"> - <ProgressBar value={this.state.currentStep} max={this.state.totalSteps} /> - {this.loginContent()} - </div> - ); - } -}); diff --git a/skins/base/views/templates/Register.js b/skins/base/views/templates/Register.js deleted file mode 100644 index 94f3b96971..0000000000 --- a/skins/base/views/templates/Register.js +++ /dev/null @@ -1,131 +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. -*/ - -'use strict'; - -var React = require('react'); - -var ComponentBroker = require("../../../../src/ComponentBroker"); - -var Loader = require("react-loader"); - -var RegisterController = require("../../../../src/controllers/templates/Register"); - -var ServerConfig = ComponentBroker.get("molecules/ServerConfig"); - -module.exports = React.createClass({ - displayName: 'Register', - mixins: [RegisterController], - - getRegFormVals: function() { - return { - email: this.refs.email.getDOMNode().value, - username: this.refs.username.getDOMNode().value, - password: this.refs.password.getDOMNode().value, - confirmPassword: this.refs.confirmPassword.getDOMNode().value - }; - }, - - getHsUrl: function() { - return this.refs.serverConfig.getHsUrl(); - }, - - getIsUrl: function() { - return this.refs.serverConfig.getIsUrl(); - }, - - componentForStep: function(step) { - switch (step) { - case 'initial': - return ( - <div> - <form onSubmit={this.onInitialStageSubmit}> - Email: <input type="text" ref="email" defaultValue={this.savedParams.email} /><br /> - Username: <input type="text" ref="username" defaultValue={this.savedParams.username} /><br /> - Password: <input type="password" ref="password" defaultValue={this.savedParams.password} /><br /> - Confirm Password: <input type="password" ref="confirmPassword" defaultValue={this.savedParams.confirmPassword} /><br /> - <ServerConfig ref="serverConfig" /> - <input type="submit" value="Continue" /> - </form> - </div> - ); - // XXX: clearly these should be separate organisms - case 'stage_m.login.email.identity': - return ( - <div> - Please check your email to continue registration. - </div> - ); - case 'stage_m.login.recaptcha': - return ( - <div ref="recaptchaContainer"> - This Home Server would like to make sure you're not a robot - <div id="mx_recaptcha"></div> - </div> - ); - } - }, - - registerContent: function() { - if (this.state.busy) { - return ( - <Loader /> - ); - } else { - return ( - <div> - <h1>Create a new account:</h1> - {this.componentForStep(this.state.step)} - <div className="error">{this.state.errorText}</div> - <a onClick={this.showLogin} href="#">Sign in with existing account</a> - </div> - ); - } - }, - - onBadFields: function(bad) { - var keys = Object.keys(bad); - var strings = []; - for (var i = 0; i < keys.length; ++i) { - switch (bad[keys[i]]) { - case this.FieldErrors.PasswordMismatch: - strings.push("Passwords don't match"); - break; - case this.FieldErrors.Missing: - strings.push("Missing "+keys[i]); - break; - case this.FieldErrors.TooShort: - strings.push(keys[i]+" is too short"); - break; - case this.FieldErrors.InUse: - strings.push(keys[i]+" is already taken"); - break; - } - } - var errtxt = strings.join(', '); - this.setState({ - errorText: errtxt - }); - }, - - render: function() { - return ( - <div className="mx_Register"> - {this.registerContent()} - </div> - ); - } -}); diff --git a/src/CallHandler.js b/src/CallHandler.js new file mode 100644 index 0000000000..d0cf16f801 --- /dev/null +++ b/src/CallHandler.js @@ -0,0 +1,302 @@ +/* +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. +*/ + +/* + * Manages a list of all the currently active calls. + * + * This handler dispatches when voip calls are added/updated/removed from this list: + * { + * action: 'call_state' + * room_id: <room ID of the call> + * } + * + * To know the state of the call, this handler exposes a getter to + * obtain the call for a room: + * var call = CallHandler.getCall(roomId) + * var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing + * + * This handler listens for and handles the following actions: + * { + * action: 'place_call', + * type: 'voice|video', + * room_id: <room that the place call button was pressed in> + * } + * + * { + * action: 'incoming_call' + * call: MatrixCall + * } + * + * { + * action: 'hangup' + * room_id: <room that the hangup button was pressed in> + * } + * + * { + * action: 'answer' + * room_id: <room that the answer button was pressed in> + * } + */ + +var MatrixClientPeg = require('./MatrixClientPeg'); +var Modal = require('./Modal'); +var sdk = require('./index'); +var Matrix = require("matrix-js-sdk"); +var dis = require("./dispatcher"); +var Modulator = require("./Modulator"); + +global.mxCalls = { + //room_id: MatrixCall +}; +var calls = global.mxCalls; + +function play(audioId) { + // TODO: Attach an invisible element for this instead + // which listens? + var audio = document.getElementById(audioId); + if (audio) { + audio.load(); + audio.play(); + } +} + +function pause(audioId) { + // TODO: Attach an invisible element for this instead + // which listens? + var audio = document.getElementById(audioId); + if (audio) { + audio.pause(); + } +} + +function _setCallListeners(call) { + call.on("error", function(err) { + console.error("Call error: %s", err); + console.error(err.stack); + call.hangup(); + _setCallState(undefined, call.roomId, "ended"); + }); + call.on("hangup", function() { + _setCallState(undefined, call.roomId, "ended"); + }); + // map web rtc states to dummy UI state + // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing + call.on("state", function(newState, oldState) { + if (newState === "ringing") { + _setCallState(call, call.roomId, "ringing"); + pause("ringbackAudio"); + } + else if (newState === "invite_sent") { + _setCallState(call, call.roomId, "ringback"); + play("ringbackAudio"); + } + else if (newState === "ended" && oldState === "connected") { + _setCallState(undefined, call.roomId, "ended"); + pause("ringbackAudio"); + play("callendAudio"); + } + else if (newState === "ended" && oldState === "invite_sent" && + (call.hangupParty === "remote" || + (call.hangupParty === "local" && call.hangupReason === "invite_timeout") + )) { + _setCallState(call, call.roomId, "busy"); + pause("ringbackAudio"); + play("busyAudio"); + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Call Timeout", + description: "The remote side failed to pick up." + }); + } + else if (oldState === "invite_sent") { + _setCallState(call, call.roomId, "stop_ringback"); + pause("ringbackAudio"); + } + else if (oldState === "ringing") { + _setCallState(call, call.roomId, "stop_ringing"); + pause("ringbackAudio"); + } + else if (newState === "connected") { + _setCallState(call, call.roomId, "connected"); + pause("ringbackAudio"); + } + }); +} + +function _setCallState(call, roomId, status) { + console.log( + "Call state in %s changed to %s (%s)", roomId, status, (call ? call.state : "-") + ); + calls[roomId] = call; + if (call) { + call.call_state = status; + } + dis.dispatch({ + action: 'call_state', + room_id: roomId + }); +} + +function _onAction(payload) { + function placeCall(newCall) { + _setCallListeners(newCall); + _setCallState(newCall, newCall.roomId, "ringback"); + if (payload.type === 'voice') { + newCall.placeVoiceCall(); + } + else if (payload.type === 'video') { + newCall.placeVideoCall( + payload.remote_element, + payload.local_element + ); + } + else { + console.error("Unknown conf call type: %s", payload.type); + } + } + + switch (payload.action) { + case 'place_call': + if (module.exports.getAnyActiveCall()) { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Existing Call", + description: "You are already in a call." + }); + return; // don't allow >1 call to be placed. + } + var room = MatrixClientPeg.get().getRoom(payload.room_id); + if (!room) { + console.error("Room %s does not exist.", payload.room_id); + return; + } + + var members = room.getJoinedMembers(); + if (members.length <= 1) { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + description: "You cannot place a call with yourself." + }); + return; + } + else if (members.length === 2) { + console.log("Place %s call in %s", payload.type, payload.room_id); + var call = Matrix.createNewMatrixCall( + MatrixClientPeg.get(), payload.room_id + ); + placeCall(call); + } + else { // > 2 + dis.dispatch({ + action: "place_conference_call", + room_id: payload.room_id, + type: payload.type, + remote_element: payload.remote_element, + local_element: payload.local_element + }); + } + break; + case 'place_conference_call': + console.log("Place conference call in %s", payload.room_id); + if (!Modulator.hasConferenceHandler()) { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + description: "Conference calls are not supported in this client" + }); + } else { + var ConferenceHandler = Modulator.getConferenceHandler(); + ConferenceHandler.createNewMatrixCall( + MatrixClientPeg.get(), payload.room_id + ).done(function(call) { + placeCall(call); + }, function(err) { + console.error("Failed to setup conference call: %s", err); + }); + } + break; + case 'incoming_call': + if (module.exports.getAnyActiveCall()) { + payload.call.hangup("busy"); + return; // don't allow >1 call to be received, hangup newer one. + } + var call = payload.call; + _setCallListeners(call); + _setCallState(call, call.roomId, "ringing"); + break; + case 'hangup': + if (!calls[payload.room_id]) { + return; // no call to hangup + } + calls[payload.room_id].hangup(); + _setCallState(null, payload.room_id, "ended"); + break; + case 'answer': + if (!calls[payload.room_id]) { + return; // no call to answer + } + calls[payload.room_id].answer(); + _setCallState(calls[payload.room_id], payload.room_id, "connected"); + dis.dispatch({ + action: "view_room", + room_id: payload.room_id + }); + break; + } +} +// FIXME: Nasty way of making sure we only register +// with the dispatcher once +if (!global.mxCallHandler) { + dis.register(_onAction); +} + +var callHandler = { + getCallForRoom: function(roomId) { + var call = module.exports.getCall(roomId); + if (call) return call; + + if (Modulator.hasConferenceHandler()) { + var ConferenceHandler = Modulator.getConferenceHandler(); + call = ConferenceHandler.getConferenceCallForRoom(roomId); + } + if (call) return call; + + return null; + }, + + getCall: function(roomId) { + return calls[roomId] || null; + }, + + getAnyActiveCall: function() { + var roomsWithCalls = Object.keys(calls); + for (var i = 0; i < roomsWithCalls.length; i++) { + if (calls[roomsWithCalls[i]] && + calls[roomsWithCalls[i]].call_state !== "ended") { + return calls[roomsWithCalls[i]]; + } + } + return null; + } +}; +// Only things in here which actually need to be global are the +// calls list (done separately) and making sure we only register +// with the dispatcher once (which uses this mechanism but checks +// separately). This could be tidied up. +if (global.mxCallHandler === undefined) { + global.mxCallHandler = callHandler; +} + +module.exports = global.mxCallHandler; diff --git a/src/ComponentBroker.js b/src/ComponentBroker.js deleted file mode 100644 index 6445e9472f..0000000000 --- a/src/ComponentBroker.js +++ /dev/null @@ -1,92 +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. -*/ - -'use strict'; - -function load(name) { - var module = require("../skins/base/views/"+name); - return module; -}; - -var ComponentBroker = function() { - this.components = {}; -}; - -ComponentBroker.prototype = { - get: function(name) { - if (this.components[name]) { - return this.components[name]; - } - - this.components[name] = load(name); - return this.components[name]; - }, - - set: function(name, module) { - this.components[name] = module; - } -}; - -// We define one Component Broker globally, because the intention is -// very much that it is a singleton. Relying on there only being one -// copy of the module can be dicey and not work as browserify's -// behaviour with multiple copies of files etc. is erratic at best. -// XXX: We can still end up with the same file twice in the resulting -// JS bundle which is nonideal. -if (global.componentBroker === undefined) { - global.componentBroker = new ComponentBroker(); -} -module.exports = global.componentBroker; - -// We need to tell browserify to include all the components -// by direct require syntax in here, but we don't want them -// to be evaluated in this file because then we wouldn't be -// able to override them. if (0) does this. -// Must be in this file (because the require is file-specific) and -// must be at the end because the components include this file. -if (0) { -require('../skins/base/views/atoms/LogoutButton'); -require('../skins/base/views/atoms/EnableNotificationsButton'); -require('../skins/base/views/atoms/MessageTimestamp'); -require('../skins/base/views/atoms/create_room/CreateRoomButton'); -require('../skins/base/views/atoms/create_room/RoomNameTextbox'); -require('../skins/base/views/atoms/create_room/Presets'); -require('../skins/base/views/atoms/EditableText'); -require('../skins/base/views/molecules/MatrixToolbar'); -require('../skins/base/views/molecules/RoomTile'); -require('../skins/base/views/molecules/MessageTile'); -require('../skins/base/views/molecules/SenderProfile'); -require('../skins/base/views/molecules/UnknownMessageTile'); -require('../skins/base/views/molecules/MTextTile'); -require('../skins/base/views/molecules/MNoticeTile'); -require('../skins/base/views/molecules/MEmoteTile'); -require('../skins/base/views/molecules/MImageTile'); -require('../skins/base/views/molecules/MFileTile'); -require('../skins/base/views/molecules/MRoomMemberTile'); -require('../skins/base/views/molecules/RoomHeader'); -require('../skins/base/views/molecules/MessageComposer'); -require('../skins/base/views/molecules/ProgressBar'); -require('../skins/base/views/molecules/ServerConfig'); -require('../skins/base/views/organisms/MemberList'); -require('../skins/base/views/molecules/MemberTile'); -require('../skins/base/views/organisms/RoomList'); -require('../skins/base/views/organisms/RoomView'); -require('../skins/base/views/templates/Login'); -require('../skins/base/views/templates/Register'); -require('../skins/base/views/organisms/Notifier'); -require('../skins/base/views/organisms/CreateRoom'); -require('../skins/base/views/molecules/UserSelector'); -} diff --git a/src/ContentMessages.js b/src/ContentMessages.js index fdd29fd58a..eba3011917 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -53,10 +53,14 @@ function sendContentToRoom(file, roomId, matrixClient) { body: file.name, info: { size: file.size, - mimetype: file.type } }; + // if we have a mime type for the file, add it to the message metadata + if (file.type) { + content.info.mimetype = file.type; + } + var def = q.defer(); if (file.type.indexOf('image/') == 0) { content.msgtype = 'm.image'; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 6b36e67e6b..62c49a5f2d 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -23,6 +23,16 @@ var matrixClient = null; var localStorage = window.localStorage; +function deviceId() { + var id = Math.floor(Math.random()*16777215).toString(16); + id = "W" + "000000".substring(id.length) + id; + if (localStorage) { + id = localStorage.getItem("mx_device_id") || id; + localStorage.setItem("mx_device_id", id); + } + return id; +} + function createClient(hs_url, is_url, user_id, access_token) { var opts = { baseUrl: hs_url, @@ -31,6 +41,11 @@ function createClient(hs_url, is_url, user_id, access_token) { userId: user_id }; + if (localStorage) { + opts.sessionStore = new Matrix.WebStorageSessionStore(localStorage); + opts.deviceId = deviceId(); + } + matrixClient = Matrix.createClient(opts); } @@ -44,23 +59,33 @@ if (localStorage) { } } -module.exports = { - get: function() { +class MatrixClient { + get() { return matrixClient; - }, + } - replaceUsingUrls: function(hs_url, is_url) { + unset() { + matrixClient = null; + } + + replaceUsingUrls(hs_url, is_url) { matrixClient = Matrix.createClient({ baseUrl: hs_url, idBaseUrl: is_url }); - }, + } - replaceUsingAccessToken: function(hs_url, is_url, user_id, access_token) { - createClient(hs_url, is_url, user_id, access_token); + replaceUsingAccessToken(hs_url, is_url, user_id, access_token) { if (localStorage) { try { localStorage.clear(); + } catch (e) { + console.warn("Error using local storage"); + } + } + createClient(hs_url, is_url, user_id, access_token); + if (localStorage) { + try { localStorage.setItem("mx_hs_url", hs_url); localStorage.setItem("mx_is_url", is_url); localStorage.setItem("mx_user_id", user_id); @@ -72,5 +97,9 @@ module.exports = { console.warn("No local storage available: can't persist session!"); } } -}; +} +if (!global.mxMatrixClient) { + global.mxMatrixClient = new MatrixClient(); +} +module.exports = global.mxMatrixClient; diff --git a/src/MatrixTools.js b/src/MatrixTools.js new file mode 100644 index 0000000000..5c6dca8b33 --- /dev/null +++ b/src/MatrixTools.js @@ -0,0 +1,36 @@ +/* +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. +*/ + +module.exports = { + /** + * Given a room object, return the canonical alias for it + * if there is one. Otherwise return null; + */ + getCanonicalAliasForRoom: function(room) { + var aliasEvents = room.currentState.getStateEvents( + "m.room.aliases" + ); + // Canonical aliases aren't implemented yet, so just return the first + for (var j = 0; j < aliasEvents.length; j++) { + var aliases = aliasEvents[j].getContent().aliases; + if (aliases && aliases.length) { + return aliases[0]; + } + } + return null; + } +} + diff --git a/src/Modal.js b/src/Modal.js new file mode 100644 index 0000000000..f34ef65d59 --- /dev/null +++ b/src/Modal.js @@ -0,0 +1,61 @@ +/* +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. +*/ + + +'use strict'; + +var React = require('react'); + +module.exports = { + DialogContainerId: "mx_Dialog_Container", + + getOrCreateContainer: function() { + var container = document.getElementById(this.DialogContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = this.DialogContainerId; + document.body.appendChild(container); + } + + return container; + }, + + createDialog: function (Element, props) { + var self = this; + + var closeDialog = function() { + React.unmountComponentAtNode(self.getOrCreateContainer()); + + if (props && props.onFinished) props.onFinished.apply(null, arguments); + }; + + // FIXME: If a dialog uses getDefaultProps it clobbers the onFinished + // property set here so you can't close the dialog from a button click! + var dialog = ( + <div className="mx_Dialog_wrapper"> + <div className="mx_Dialog"> + <Element {...props} onFinished={closeDialog}/> + </div> + <div className="mx_Dialog_background" onClick={closeDialog}></div> + </div> + ); + + React.render(dialog, this.getOrCreateContainer()); + + return {close: closeDialog}; + }, +}; diff --git a/src/Modulator.js b/src/Modulator.js new file mode 100644 index 0000000000..72fcc14d89 --- /dev/null +++ b/src/Modulator.js @@ -0,0 +1,111 @@ +/* +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. +*/ + +/** + * The modulator stores 'modules': classes that provide + * functionality and are not React UI components. + * Modules go into named slots, eg. a conference calling + * module goes into the 'conference' slot. If two modules + * that use the same slot are loaded, this is considered + * to be an error. + * + * There are some module slots that the react SDK knows + * about natively: these have explicit getters. + * + * A module must define: + * - 'slot' (string): The name of the slot it goes into + * and may define: + * - 'start' (function): Called on module load + * - 'stop' (function): Called on module unload + */ +class Modulator { + constructor() { + this.modules = {}; + } + + getModule(name) { + var m = this.getModuleOrNull(name); + if (m === null) { + throw new Error("No such module: "+name); + } + return m; + } + + getModuleOrNull(name) { + if (this.modules == {}) { + throw new Error( + "Attempted to get a module before a skin has been loaded."+ + "This is probably because a component has called "+ + "getModule at the root level." + ); + } + var module = this.modules[name]; + if (module) { + return module; + } + return null; + } + + hasModule(name) { + var m = this.getModuleOrNull(name); + return m !== null; + } + + loadModule(moduleObject) { + if (!moduleObject.slot) { + throw new Error( + "Attempted to load something that is not a module "+ + "(does not have a slot name)" + ); + } + if (this.modules[moduleObject.slot] !== undefined) { + throw new Error( + "Cannot load module: slot '"+moduleObject.slot+"' is occupied!" + ); + } + this.modules[moduleObject.slot] = moduleObject; + } + + reset() { + var keys = Object.keys(this.modules); + for (var i = 0; i < keys.length; ++i) { + var k = keys[i]; + var m = this.modules[k]; + + if (m.stop) m.stop(); + } + this.modules = {}; + } + + // *********** + // known slots + // *********** + + getConferenceHandler() { + return this.getModule('conference'); + } + + hasConferenceHandler() { + return this.hasModule('conference'); + } +} + +// Define one Modulator globally (see Skinner.js) +if (global.mxModulator === undefined) { + global.mxModulator = new Modulator(); +} +module.exports = global.mxModulator; + diff --git a/src/Presence.js b/src/Presence.js new file mode 100644 index 0000000000..d77058abd8 --- /dev/null +++ b/src/Presence.js @@ -0,0 +1,107 @@ +/* +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. +*/ + +var MatrixClientPeg = require("./MatrixClientPeg"); + + // Time in ms after that a user is considered as unavailable/away +var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins +var PRESENCE_STATES = ["online", "offline", "unavailable"]; + +// The current presence state +var state, timer; + +module.exports = { + + /** + * Start listening the user activity to evaluate his presence state. + * Any state change will be sent to the Home Server. + */ + start: function() { + var self = this; + this.running = true; + if (undefined === state) { + // The user is online if they move the mouse or press a key + document.onmousemove = function() { self._resetTimer(); }; + document.onkeypress = function() { self._resetTimer(); }; + this._resetTimer(); + } + }, + + /** + * Stop tracking user activity + */ + stop: function() { + this.running = false; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + state = undefined; + }, + + /** + * Get the current presence state. + * @returns {string} the presence state (see PRESENCE enum) + */ + getState: function() { + return state; + }, + + /** + * Set the presence state. + * If the state has changed, the Home Server will be notified. + * @param {string} newState the new presence state (see PRESENCE enum) + */ + setState: function(newState) { + if (newState === state) { + return; + } + if (PRESENCE_STATES.indexOf(newState) === -1) { + throw new Error("Bad presence state: " + newState); + } + if (!this.running) { + return; + } + state = newState; + MatrixClientPeg.get().setPresence(state).done(function() { + console.log("Presence: %s", newState); + }, function(err) { + console.error("Failed to set presence: %s", err); + }); + }, + + /** + * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. + * @private + */ + _onUnavailableTimerFire: function() { + this.setState("unavailable"); + }, + + /** + * Callback called when the user made an action on the page + * @private + */ + _resetTimer: function() { + var self = this; + this.setState("online"); + // Re-arm the timer + clearTimeout(timer); + timer = setTimeout(function() { + self._onUnavailableTimerFire(); + }, UNAVAILABLE_TIME_MS); + } +}; diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js index bc7a001670..730a0de18b 100644 --- a/src/RoomListSorter.js +++ b/src/RoomListSorter.js @@ -17,7 +17,12 @@ limitations under the License. 'use strict'; function tsOfNewestEvent(room) { - return room.timeline[room.timeline.length - 1].getTs(); + if (room.timeline.length) { + return room.timeline[room.timeline.length - 1].getTs(); + } + else { + return Number.MAX_SAFE_INTEGER; + } } function mostRecentActivityFirst(roomList) { diff --git a/src/Skinner.js b/src/Skinner.js new file mode 100644 index 0000000000..ae48d85633 --- /dev/null +++ b/src/Skinner.js @@ -0,0 +1,63 @@ +/* +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. +*/ + +class Skinner { + constructor() { + this.components = null; + } + + getComponent(name) { + if (this.components === null) { + throw new Error( + "Attempted to get a component before a skin has been loaded."+ + "This is probably because either:"+ + " a) Your app has not called sdk.loadSkin(), or"+ + " b) A component has called getComponent at the root level" + ); + } + var comp = this.components[name]; + if (comp) { + return comp; + } + throw new Error("No such component: "+name); + } + + load(skinObject) { + if (this.components !== null) { + throw new Error( + "Attempted to load a skin while a skin is already loaded"+ + "If you want to change the active skin, call resetSkin first" + ); + } + this.components = skinObject; + } + + reset() { + this.components = null; + } +} + +// We define one Skinner globally, because the intention is +// very much that it is a singleton. Relying on there only being one +// copy of the module can be dicey and not work as browserify's +// behaviour with multiple copies of files etc. is erratic at best. +// XXX: We can still end up with the same file twice in the resulting +// JS bundle which is nonideal. +if (global.mxSkinner === undefined) { + global.mxSkinner = new Skinner(); +} +module.exports = global.mxSkinner; + diff --git a/src/SlashCommands.js b/src/SlashCommands.js new file mode 100644 index 0000000000..08d68331f8 --- /dev/null +++ b/src/SlashCommands.js @@ -0,0 +1,312 @@ +/* +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. +*/ + +var MatrixClientPeg = require("./MatrixClientPeg"); +var dis = require("./dispatcher"); +var encryption = require("./encryption"); + +var reject = function(msg) { + return { + error: msg + }; +}; + +var success = function(promise) { + return { + promise: promise + }; +}; + +var commands = { + // Change your nickname + nick: function(room_id, args) { + if (args) { + return success( + MatrixClientPeg.get().setDisplayName(args) + ); + } + return reject("Usage: /nick <display_name>"); + }, + + 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("Usage: encrypt <on/off>"); + }, + + // Change the room topic + topic: function(room_id, args) { + if (args) { + return success( + MatrixClientPeg.get().setRoomTopic(room_id, args) + ); + } + return reject("Usage: /topic <topic>"); + }, + + // Invite a user + invite: function(room_id, args) { + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + return success( + MatrixClientPeg.get().invite(room_id, matches[1]) + ); + } + } + return reject("Usage: /invite <userId>"); + }, + + // Join a room + join: 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"); + } + if (!room_alias.match(/:/)) { + var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); + room_alias += ':' + domain; + } + + // Try to find a room with this alias + var rooms = MatrixClientPeg.get().getRooms(); + var roomId; + 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) { + roomId = rooms[i].roomId; + break; + } + } + if (roomId) { break; } + } + if (roomId) { break; } + } + if (roomId) { // we've already joined this room, view it. + dis.dispatch({ + action: 'view_room', + room_id: roomId + }); + return success(); + } + else { + // attempt to join this alias. + return success( + MatrixClientPeg.get().joinRoom(room_alias).then( + function(room) { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId + }); + }) + ); + } + } + } + return reject("Usage: /join <room_alias>"); + }, + + part: function(room_id, args) { + var targetRoomId; + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + var room_alias = matches[1]; + if (room_alias[0] !== '#') { + return reject("Usage: /part [#alias:domain]"); + } + if (!room_alias.match(/:/)) { + var domain = MatrixClientPeg.get().credentials.userId.replace(/^.*:/, ''); + room_alias += ':' + domain; + } + + // Try to find a room with this alias + var rooms = MatrixClientPeg.get().getRooms(); + 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) { + targetRoomId = rooms[i].roomId; + break; + } + } + if (targetRoomId) { break; } + } + if (targetRoomId) { break; } + } + } + if (!targetRoomId) { + return reject("Unrecognised room alias: " + room_alias); + } + } + if (!targetRoomId) targetRoomId = room_id; + return success( + MatrixClientPeg.get().leave(targetRoomId).then( + function() { + dis.dispatch({action: 'view_next_room'}); + }) + ); + }, + + // Kick a user from the room with an optional reason + kick: function(room_id, args) { + if (args) { + var matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + return success( + MatrixClientPeg.get().kick(room_id, matches[1], matches[3]) + ); + } + } + return reject("Usage: /kick <userId> [<reason>]"); + }, + + // Ban a user from the room with an optional reason + ban: function(room_id, args) { + if (args) { + var matches = args.match(/^(\S+?)( +(.*))?$/); + if (matches) { + return success( + MatrixClientPeg.get().ban(room_id, matches[1], matches[3]) + ); + } + } + return reject("Usage: /ban <userId> [<reason>]"); + }, + + // Unban a user from the room + unban: function(room_id, args) { + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + // Reset the user membership to "leave" to unban him + return success( + MatrixClientPeg.get().unban(room_id, matches[1]) + ); + } + } + return reject("Usage: /unban <userId>"); + }, + + // Define the power level of a user + op: function(room_id, args) { + if (args) { + var matches = args.match(/^(\S+?)( +(\d+))?$/); + var powerLevel = 50; // default power level for op + if (matches) { + var user_id = matches[1]; + if (matches.length === 4 && undefined !== matches[3]) { + powerLevel = parseInt(matches[3]); + } + if (powerLevel !== NaN) { + var room = MatrixClientPeg.get().getRoom(room_id); + if (!room) { + return reject("Bad room ID: " + room_id); + } + var powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + return success( + MatrixClientPeg.get().setPowerLevel( + room_id, user_id, powerLevel, powerLevelEvent + ) + ); + } + } + } + return reject("Usage: /op <userId> [<power level>]"); + }, + + // Reset the power level of a user + deop: function(room_id, args) { + if (args) { + var matches = args.match(/^(\S+)$/); + if (matches) { + var room = MatrixClientPeg.get().getRoom(room_id); + if (!room) { + return reject("Bad room ID: " + room_id); + } + + var powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + return success( + MatrixClientPeg.get().setPowerLevel( + room_id, args, undefined, powerLevelEvent + ) + ); + } + } + return reject("Usage: /deop <userId>"); + } +}; + +// helpful aliases +commands.j = commands.join; + +module.exports = { + /** + * Process the given text for /commands and perform them. + * @param {string} roomId The room in which the command was performed. + * @param {string} input The raw text input by the user. + * @return {Object|null} An object with the property 'error' if there was an error + * processing the command, or 'promise' if a request was sent out. + * Returns null if the input didn't match a command. + */ + processInput: function(roomId, input) { + // trim any trailing whitespace, as it can confuse the parser for + // IRC-style commands + input = input.replace(/\s+$/, ""); + if (input[0] === "/" && input[1] !== "/") { + var bits = input.match(/^(\S+?)( +(.*))?$/); + var cmd = bits[1].substring(1).toLowerCase(); + var args = bits[3]; + if (cmd === "me") return null; + if (commands[cmd]) { + return commands[cmd](roomId, args); + } + else { + return reject("Unrecognised command: " + input); + } + } + return null; // not a command + } +}; diff --git a/src/TextForEvent.js b/src/TextForEvent.js new file mode 100644 index 0000000000..3d6ba2cf64 --- /dev/null +++ b/src/TextForEvent.js @@ -0,0 +1,106 @@ + +function textForMemberEvent(ev) { + // XXX: SYJS-16 + var senderName = ev.sender ? ev.sender.name : ev.getSender(); + var targetName = ev.target ? ev.target.name : ev.getStateKey(); + var reason = ev.getContent().reason ? ( + " Reason: " + ev.getContent().reason + ) : ""; + switch (ev.getContent().membership) { + case 'invite': + return senderName + " invited " + targetName + "."; + case 'ban': + return senderName + " banned " + targetName + "." + reason; + case 'join': + if (ev.getPrevContent() && ev.getPrevContent().membership == 'join') { + if (ev.getPrevContent().displayname && ev.getContent().displayname && ev.getPrevContent().displayname != ev.getContent().displayname) { + return ev.getSender() + " changed their display name from " + + ev.getPrevContent().displayname + " to " + + ev.getContent().displayname; + } else if (!ev.getPrevContent().displayname && ev.getContent().displayname) { + return ev.getSender() + " set their display name to " + ev.getContent().displayname; + } else if (ev.getPrevContent().displayname && !ev.getContent().displayname) { + return ev.getSender() + " removed their display name"; + } else if (ev.getPrevContent().avatar_url && !ev.getContent().avatar_url) { + return ev.getSender() + " removed their profile picture"; + } else if (ev.getPrevContent().avatar_url && ev.getContent().avatar_url && ev.getPrevContent().avatar_url != ev.getContent().avatar_url) { + return ev.getSender() + " changed their profile picture"; + } else if (!ev.getPrevContent().avatar_url && ev.getContent().avatar_url) { + return ev.getSender() + " set a profile picture"; + } + } else { + if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); + return targetName + " joined the room."; + } + return ''; + case 'leave': + if (ev.getSender() === ev.getStateKey()) { + return targetName + " left the room."; + } + else if (ev.getPrevContent().membership === "ban") { + return senderName + " unbanned " + targetName + "."; + } + else if (ev.getPrevContent().membership === "join") { + return senderName + " kicked " + targetName + "." + reason; + } + else { + return targetName + " left the room."; + } + } +}; + +function textForTopicEvent(ev) { + var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + + return senderDisplayName + ' changed the topic to, "' + ev.getContent().topic + '"'; +}; + +function textForMessageEvent(ev) { + var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + + var message = senderDisplayName + ': ' + ev.getContent().body; + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = senderDisplayName + " sent an image."; + } + return message; +}; + +function textForCallAnswerEvent(event) { + var senderName = event.sender ? event.sender.name : "Someone"; + return senderName + " answered the call."; +}; + +function textForCallHangupEvent(event) { + var senderName = event.sender ? event.sender.name : "Someone"; + return senderName + " ended the call."; +}; + +function textForCallInviteEvent(event) { + var senderName = event.sender ? event.sender.name : "Someone"; + // FIXME: Find a better way to determine this from the event? + var type = "voice"; + if (event.getContent().offer && event.getContent().offer.sdp && + event.getContent().offer.sdp.indexOf('m=video') !== -1) { + type = "video"; + } + return senderName + " placed a " + type + " call."; +}; + +var handlers = { + 'm.room.message': textForMessageEvent, + 'm.room.topic': textForTopicEvent, + 'm.room.member': textForMemberEvent, + 'm.call.invite': textForCallInviteEvent, + 'm.call.answer': textForCallAnswerEvent, + 'm.call.hangup': textForCallHangupEvent, +}; + +module.exports = { + textForEvent: function(ev) { + var hdlr = handlers[ev.getType()]; + if (!hdlr) return ""; + return hdlr(ev); + } +} diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js new file mode 100644 index 0000000000..4fb5399027 --- /dev/null +++ b/src/WhoIsTyping.js @@ -0,0 +1,49 @@ +var MatrixClientPeg = require("./MatrixClientPeg"); + +module.exports = { + usersTypingApartFromMe: function(room) { + return this.usersTyping( + room, [MatrixClientPeg.get().credentials.userId] + ); + }, + + /** + * Given a Room object and, optionally, a list of userID strings + * to exclude, return a list of user objects who are typing. + */ + usersTyping: function(room, exclude) { + var whoIsTyping = []; + + if (exclude === undefined) { + exclude = []; + } + + var memberKeys = Object.keys(room.currentState.members); + for (var i = 0; i < memberKeys.length; ++i) { + var userId = memberKeys[i]; + + if (room.currentState.members[userId].typing) { + if (exclude.indexOf(userId) == -1) { + whoIsTyping.push(room.currentState.members[userId]); + } + } + } + + return whoIsTyping; + }, + + whoIsTypingString: function(room) { + var whoIsTyping = this.usersTypingApartFromMe(room); + if (whoIsTyping.length == 0) { + return null; + } else if (whoIsTyping.length == 1) { + return whoIsTyping[0].name + ' is typing'; + } else { + var names = whoIsTyping.map(function(m) { + return m.name; + }); + var lastPerson = names.shift(); + return names.join(', ') + ' and ' + lastPerson + ' are typing'; + } + } +} diff --git a/src/controllers/atoms/EditableText.js b/src/controllers/atoms/EditableText.js index ac46973613..5ea4ce8c4a 100644 --- a/src/controllers/atoms/EditableText.js +++ b/src/controllers/atoms/EditableText.js @@ -21,7 +21,9 @@ var React = require('react'); module.exports = { propTypes: { onValueChanged: React.PropTypes.func, - initalValue: React.PropTypes.string, + initialValue: React.PropTypes.string, + label: React.PropTypes.string, + placeHolder: React.PropTypes.string, }, Phases: { @@ -32,37 +34,55 @@ module.exports = { getDefaultProps: function() { return { onValueChanged: function() {}, - initalValue: '', + initialValue: '', + label: 'Click to set', + placeholder: '', }; }, getInitialState: function() { return { - value: this.props.initalValue, + value: this.props.initialValue, phase: this.Phases.Display, } }, + componentWillReceiveProps: function(nextProps) { + this.setState({ + value: nextProps.initialValue + }); + }, + getValue: function() { return this.state.value; }, - setValue: function(val) { + setValue: function(val, shouldSubmit, suppressListener) { + var self = this; this.setState({ value: val, phase: this.Phases.Display, + }, function() { + if (!suppressListener) { + self.onValueChanged(shouldSubmit); + } }); + }, - this.onValueChanged(); + edit: function() { + this.setState({ + phase: this.Phases.Edit, + }); }, cancelEdit: function() { this.setState({ phase: this.Phases.Display, }); + this.onValueChanged(false); }, - onValueChanged: function() { - this.props.onValueChanged(this.state.value); + onValueChanged: function(shouldSubmit) { + this.props.onValueChanged(this.state.value, shouldSubmit); }, }; diff --git a/src/controllers/atoms/EnableNotificationsButton.js b/src/controllers/atoms/EnableNotificationsButton.js index c600f33013..3c399484e8 100644 --- a/src/controllers/atoms/EnableNotificationsButton.js +++ b/src/controllers/atoms/EnableNotificationsButton.js @@ -15,53 +15,44 @@ limitations under the License. */ 'use strict'; +var sdk = require('../../index'); +var dis = require("../../dispatcher"); module.exports = { - notificationsAvailable: function() { - return !!global.Notification; + + componentDidMount: function() { + this.dispatcherRef = dis.register(this.onAction); }, - havePermission: function() { - return global.Notification.permission == 'granted'; + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + }, + + onAction: function(payload) { + if (payload.action !== "notifier_enabled") { + return; + } + this.forceUpdate(); }, enabled: function() { - if (!this.havePermission()) return false; - - if (!global.localStorage) return true; - - var enabled = global.localStorage.getItem('notifications_enabled'); - if (enabled === null) return true; - return enabled === 'true'; - }, - - disable: function() { - if (!global.localStorage) return; - global.localStorage.setItem('notifications_enabled', 'false'); - this.forceUpdate(); - }, - - enable: function() { - if (!this.havePermission()) { - var that = this; - global.Notification.requestPermission(function() { - that.forceUpdate(); - }); - } - - if (!global.localStorage) return; - global.localStorage.setItem('notifications_enabled', 'true'); - this.forceUpdate(); + var Notifier = sdk.getComponent('organisms.Notifier'); + return Notifier.isEnabled(); }, onClick: function() { - if (!this.notificationsAvailable()) { + var Notifier = sdk.getComponent('organisms.Notifier'); + var self = this; + if (!Notifier.supportsDesktopNotifications()) { return; } - if (!this.enabled()) { - this.enable(); + if (!Notifier.isEnabled()) { + Notifier.setEnabled(true, function() { + self.forceUpdate(); + }); } else { - this.disable(); + Notifier.setEnabled(false); } + this.forceUpdate(); }, }; diff --git a/src/controllers/atoms/MemberAvatar.js b/src/controllers/atoms/MemberAvatar.js new file mode 100644 index 0000000000..5d93f99947 --- /dev/null +++ b/src/controllers/atoms/MemberAvatar.js @@ -0,0 +1,75 @@ +/* +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. +*/ + +'use strict'; + +var React = require('react'); +var MatrixClientPeg = require('../../MatrixClientPeg'); + +module.exports = { + propTypes: { + member: React.PropTypes.object.isRequired, + width: React.PropTypes.number, + height: React.PropTypes.number, + resizeMethod: React.PropTypes.string, + }, + + getDefaultProps: function() { + return { + width: 40, + height: 40, + resizeMethod: 'crop' + } + }, + + defaultAvatarUrl: function(member, width, height, resizeMethod) { + if (this.skinnedDefaultAvatarUrl) { + return this.skinnedDefaultAvatarUrl(member, width, height, resizeMethod); + } + return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9QjNbxSKP4eagAFnTseHFErFYLBaLxWKxWCwWi8Vi8cX4CzAABSwCRWJw31gAAAAASUVORK5CYII="; + }, + + onError: function(ev) { + // don't tightloop if the browser can't load a data url + if (ev.target.src == this.defaultAvatarUrl(this.props.member)) { + return; + } + this.setState({ + imageUrl: this.defaultAvatarUrl(this.props.member) + }); + }, + + getInitialState: function() { + var url = MatrixClientPeg.get().getAvatarUrlForMember( + this.props.member, + this.props.width, + this.props.height, + this.props.resizeMethod, + false + ); + if (!url) { + url = this.defaultAvatarUrl( + this.props.member, + this.props.width, + this.props.height, + this.props.resizeMethod + ); + } + return { + imageUrl: url + }; + } +}; diff --git a/src/controllers/atoms/RoomAvatar.js b/src/controllers/atoms/RoomAvatar.js new file mode 100644 index 0000000000..481483949a --- /dev/null +++ b/src/controllers/atoms/RoomAvatar.js @@ -0,0 +1,61 @@ +/* +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. +*/ + +'use strict'; + +var MatrixClientPeg = require('../../MatrixClientPeg'); + +module.exports = { + getDefaultProps: function() { + return { + width: 40, + height: 40, + resizeMethod: 'crop' + } + }, + + avatarUrlForRoom: function(room) { + var url = MatrixClientPeg.get().getAvatarUrlForRoom( + room, + this.props.width, this.props.height, this.props.resizeMethod, + false + ); + if (url === null) { + url = this.defaultAvatarUrl(room); + } + return url; + }, + + defaultAvatarUrl: function(member) { + return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAIAAAADnC86AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAADRJREFUeNrszQENADAIACB9QjNbxSKP4eagAFnTseHFErFYLBaLxWKxWCwWi8Vi8cX4CzAABSwCRWJw31gAAAAASUVORK5CYII="; + }, + + onError: function(ev) { + // don't tightloop if the browser can't load a data url + if (ev.target.src == this.defaultAvatarUrl(this.props.room)) { + return; + } + this.setState({ + imageUrl: this.defaultAvatarUrl(this.props.room) + }); + }, + + getInitialState: function() { + return { + imageUrl: this.avatarUrlForRoom(this.props.room) + }; + } +}; diff --git a/src/controllers/atoms/create_room/Presets.js b/src/controllers/atoms/create_room/Presets.js index 5ff7327e5a..bcc2f51481 100644 --- a/src/controllers/atoms/create_room/Presets.js +++ b/src/controllers/atoms/create_room/Presets.js @@ -18,24 +18,23 @@ limitations under the License. var React = require('react'); +var Presets = { + PrivateChat: "private_chat", + PublicChat: "public_chat", + Custom: "custom", +}; + module.exports = { propTypes: { - default_preset: React.PropTypes.string + onChange: React.PropTypes.func, + preset: React.PropTypes.string }, + Presets: Presets, + getDefaultProps: function() { return { - default_preset: 'private_chat', + onChange: function() {}, }; }, - - getInitialState: function() { - return { - preset: this.props.default_preset, - } - }, - - getPreset: function() { - return this.state.preset; - }, }; diff --git a/src/controllers/atoms/create_room/RoomAlias.js b/src/controllers/atoms/create_room/RoomAlias.js new file mode 100644 index 0000000000..b1176a2ab5 --- /dev/null +++ b/src/controllers/atoms/create_room/RoomAlias.js @@ -0,0 +1,47 @@ +/* +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. +*/ + +var React = require('react'); + +module.exports = { + propTypes: { + // Specifying a homeserver will make magical things happen when you, + // e.g. start typing in the room alias box. + homeserver: React.PropTypes.string, + alias: React.PropTypes.string, + onChange: React.PropTypes.func, + }, + + getDefaultProps: function() { + return { + onChange: function() {}, + alias: '', + }; + }, + + getAliasLocalpart: function() { + var room_alias = this.props.alias; + + if (room_alias && this.props.homeserver) { + var suffix = ":" + this.props.homeserver; + if (room_alias.startsWith("#") && room_alias.endsWith(suffix)) { + room_alias = room_alias.slice(1, -suffix.length); + } + } + + return room_alias; + }, +}; diff --git a/skins/base/css/molecules/MImageTile.css b/src/controllers/atoms/voip/VideoFeed.js similarity index 96% rename from skins/base/css/molecules/MImageTile.css rename to src/controllers/atoms/voip/VideoFeed.js index 775ebca925..3d34134daa 100644 --- a/skins/base/css/molecules/MImageTile.css +++ b/src/controllers/atoms/voip/VideoFeed.js @@ -14,6 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MImageTile { -} +module.exports = { +}; diff --git a/src/controllers/molecules/ChangeAvatar.js b/src/controllers/molecules/ChangeAvatar.js new file mode 100644 index 0000000000..0df93b02fd --- /dev/null +++ b/src/controllers/molecules/ChangeAvatar.js @@ -0,0 +1,67 @@ +/* +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. +*/ + +var React = require('react'); +var MatrixClientPeg = require("../../MatrixClientPeg"); + +module.exports = { + propTypes: { + onFinished: React.PropTypes.func, + initialAvatarUrl: React.PropTypes.string.isRequired, + }, + + Phases: { + Display: "display", + Uploading: "uploading", + Error: "error", + }, + + getDefaultProps: function() { + return { + onFinished: function() {}, + }; + }, + + getInitialState: function() { + return { + avatarUrl: this.props.initialAvatarUrl, + phase: this.Phases.Display, + } + }, + + setAvatarFromFile: function(file) { + var newUrl = null; + + this.setState({ + phase: this.Phases.Uploading + }); + var self = this; + MatrixClientPeg.get().uploadContent(file).then(function(url) { + newUrl = url; + return MatrixClientPeg.get().setAvatarUrl(url); + }).done(function() { + self.setState({ + phase: self.Phases.Display, + avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(newUrl) + }); + }, function(error) { + self.setState({ + phase: this.Phases.Error + }); + self.onError(error); + }); + }, +} diff --git a/src/controllers/molecules/ChangePassword.js b/src/controllers/molecules/ChangePassword.js new file mode 100644 index 0000000000..637e133a79 --- /dev/null +++ b/src/controllers/molecules/ChangePassword.js @@ -0,0 +1,76 @@ +/* +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. +*/ + +'use strict'; + +var React = require('react'); +var MatrixClientPeg = require("../../MatrixClientPeg"); + +module.exports = { + propTypes: { + onFinished: React.PropTypes.func, + }, + + Phases: { + Edit: "edit", + Uploading: "uploading", + Error: "error", + Success: "Success" + }, + + getDefaultProps: function() { + return { + onFinished: function() {}, + }; + }, + + getInitialState: function() { + return { + phase: this.Phases.Edit, + errorString: '' + } + }, + + changePassword: function(old_password, new_password) { + var cli = MatrixClientPeg.get(); + + var authDict = { + type: 'm.login.password', + user: cli.credentials.userId, + password: old_password + }; + + this.setState({ + phase: this.Phases.Uploading, + errorString: '', + }) + + var d = cli.setPassword(authDict, new_password); + + var self = this; + d.then(function() { + self.setState({ + phase: self.Phases.Success, + errorString: '', + }) + }, function(err) { + self.setState({ + phase: self.Phases.Error, + errorString: err.toString() + }) + }); + }, +} diff --git a/skins/base/css/organisms/RoomList.css b/src/controllers/molecules/EventAsTextTile.js similarity index 96% rename from skins/base/css/organisms/RoomList.css rename to src/controllers/molecules/EventAsTextTile.js index e2dec3c9fd..3d34134daa 100644 --- a/skins/base/css/organisms/RoomList.css +++ b/src/controllers/molecules/EventAsTextTile.js @@ -14,5 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomList { -} +module.exports = { +}; + diff --git a/src/controllers/molecules/MEmoteTile.js b/src/controllers/molecules/MEmoteTile.js index 8aa688b21e..1fb117ceef 100644 --- a/src/controllers/molecules/MEmoteTile.js +++ b/src/controllers/molecules/MEmoteTile.js @@ -16,6 +16,15 @@ limitations under the License. 'use strict'; +var linkify = require('linkifyjs'); +var linkifyElement = require('linkifyjs/element'); +var linkifyMatrix = require('../../linkify-matrix'); + +linkifyMatrix(linkify); + module.exports = { + componentDidMount: function() { + linkifyElement(this.refs.content.getDOMNode(), linkifyMatrix.options); + } }; diff --git a/src/controllers/molecules/MemberInfo.js b/src/controllers/molecules/MemberInfo.js new file mode 100644 index 0000000000..24e4afe5fd --- /dev/null +++ b/src/controllers/molecules/MemberInfo.js @@ -0,0 +1,317 @@ +/* +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. +*/ + +/* + * State vars: + * 'can': { + * kick: boolean, + * ban: boolean, + * mute: boolean, + * modifyLevel: boolean + * }, + * 'muted': boolean, + * 'isTargetMod': boolean + */ + +var MatrixClientPeg = require("../../MatrixClientPeg"); +var dis = require("../../dispatcher"); +var Modal = require("../../Modal"); +var sdk = require('../../index'); +var Loader = require("react-loader"); + +module.exports = { + componentDidMount: function() { + // work out the current state + if (this.props.member) { + var memberState = this._calculateOpsPermissions(); + this.setState(memberState); + } + }, + + onKick: function() { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var roomId = this.props.member.roomId; + var target = this.props.member.userId; + MatrixClientPeg.get().kick(roomId, target).done(function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Kick success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Kick error", + description: err.message + }); + }); + this.props.onFinished(); + }, + + onBan: function() { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var roomId = this.props.member.roomId; + var target = this.props.member.userId; + MatrixClientPeg.get().ban(roomId, target).done(function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Ban success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Ban error", + description: err.message + }); + }); + this.props.onFinished(); + }, + + onMuteToggle: function() { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var roomId = this.props.member.roomId; + var target = this.props.member.userId; + var room = MatrixClientPeg.get().getRoom(roomId); + if (!room) { + this.props.onFinished(); + return; + } + var powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + if (!powerLevelEvent) { + this.props.onFinished(); + return; + } + var isMuted = this.state.muted; + var powerLevels = powerLevelEvent.getContent(); + var levelToSend = ( + (powerLevels.events ? powerLevels.events["m.room.message"] : null) || + powerLevels.events_default + ); + var level; + if (isMuted) { // unmute + level = levelToSend; + } + else { // mute + level = levelToSend - 1; + } + + MatrixClientPeg.get().setPowerLevel(roomId, target, level, powerLevelEvent).done( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Mute toggle success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Mute error", + description: err.message + }); + }); + this.props.onFinished(); + }, + + onModToggle: function() { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var roomId = this.props.member.roomId; + var target = this.props.member.userId; + var room = MatrixClientPeg.get().getRoom(roomId); + if (!room) { + this.props.onFinished(); + return; + } + var powerLevelEvent = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + if (!powerLevelEvent) { + this.props.onFinished(); + return; + } + var me = room.getMember(MatrixClientPeg.get().credentials.userId); + if (!me) { + this.props.onFinished(); + return; + } + var defaultLevel = powerLevelEvent.getContent().users_default; + var modLevel = me.powerLevel - 1; + // toggle the level + var newLevel = this.state.isTargetMod ? defaultLevel : modLevel; + MatrixClientPeg.get().setPowerLevel(roomId, target, newLevel, powerLevelEvent).done( + function() { + // NO-OP; rely on the m.room.member event coming down else we could + // get out of sync if we force setState here! + console.log("Mod toggle success"); + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Mod error", + description: err.message + }); + }); + this.props.onFinished(); + }, + + onChatClick: function() { + // 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 rooms = MatrixClientPeg.get().getRooms(); + var userIds = [ + this.props.member.userId, + MatrixClientPeg.get().credentials.userId + ]; + var existingRoomId = null; + for (var i = 0; i < rooms.length; i++) { + var members = rooms[i].getJoinedMembers(); + if (members.length === 2) { + var hasTargetUsers = true; + for (var j = 0; j < members.length; j++) { + if (userIds.indexOf(members[j].userId) === -1) { + hasTargetUsers = false; + break; + } + } + if (hasTargetUsers) { + existingRoomId = rooms[i].roomId; + break; + } + } + } + + if (existingRoomId) { + dis.dispatch({ + action: 'view_room', + room_id: existingRoomId + }); + } + else { + MatrixClientPeg.get().createRoom({ + invite: [this.props.member.userId], + preset: "private_chat" + }).done(function(res) { + dis.dispatch({ + action: 'view_room', + room_id: res.room_id + }); + }, function(err) { + console.error( + "Failed to create room: %s", JSON.stringify(err) + ); + }); + } + this.props.onFinished(); + }, + + // FIXME: this is horribly duplicated with MemberTile's onLeaveClick. + // Not sure what the right solution to this is. + onLeaveClick: function() { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var QuestionDialog = sdk.getComponent("organisms.QuestionDialog"); + + var roomId = this.props.member.roomId; + Modal.createDialog(QuestionDialog, { + title: "Leave room", + description: "Are you sure you want to leave the room?", + onFinished: function(should_leave) { + if (should_leave) { + var d = MatrixClientPeg.get().leave(roomId); + + var modal = Modal.createDialog(Loader); + + d.then(function() { + modal.close(); + dis.dispatch({action: 'view_next_room'}); + }, function(err) { + modal.close(); + Modal.createDialog(ErrorDialog, { + title: "Failed to leave room", + description: err.toString() + }); + }); + } + } + }); + this.props.onFinished(); + }, + + getInitialState: function() { + return { + can: { + kick: false, + ban: false, + mute: false, + modifyLevel: false + }, + muted: false, + isTargetMod: false + } + }, + + _calculateOpsPermissions: function() { + var defaultPerms = { + can: {}, + muted: false, + modifyLevel: false + }; + var room = MatrixClientPeg.get().getRoom(this.props.member.roomId); + if (!room) { + return defaultPerms; + } + var powerLevels = room.currentState.getStateEvents( + "m.room.power_levels", "" + ); + if (!powerLevels) { + return defaultPerms; + } + var me = room.getMember(MatrixClientPeg.get().credentials.userId); + var them = this.props.member; + return { + can: this._calculateCanPermissions( + me, them, powerLevels.getContent() + ), + muted: this._isMuted(them, powerLevels.getContent()), + isTargetMod: them.powerLevel > powerLevels.getContent().users_default + }; + }, + + _calculateCanPermissions: function(me, them, powerLevels) { + var can = { + kick: false, + ban: false, + mute: false, + modifyLevel: false + }; + var canAffectUser = them.powerLevel < me.powerLevel; + if (!canAffectUser) { + //console.log("Cannot affect user: %s >= %s", them.powerLevel, me.powerLevel); + return can; + } + var editPowerLevel = ( + (powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || + powerLevels.state_default + ); + can.kick = me.powerLevel >= powerLevels.kick; + can.ban = me.powerLevel >= powerLevels.ban; + can.mute = me.powerLevel >= editPowerLevel; + can.modifyLevel = me.powerLevel > them.powerLevel; + return can; + }, + + _isMuted: function(member, powerLevelContent) { + if (!powerLevelContent || !member) { + return false; + } + var levelToSend = ( + (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) || + powerLevelContent.events_default + ); + return member.powerLevel < levelToSend; + } +}; + diff --git a/src/controllers/molecules/MemberTile.js b/src/controllers/molecules/MemberTile.js index 811d2a78b1..53cae43ccd 100644 --- a/src/controllers/molecules/MemberTile.js +++ b/src/controllers/molecules/MemberTile.js @@ -17,14 +17,42 @@ limitations under the License. 'use strict'; var dis = require("../../dispatcher"); +var Modal = require("../../Modal"); +var sdk = require('../../index.js'); +var Loader = require("react-loader"); var MatrixClientPeg = require("../../MatrixClientPeg"); module.exports = { - onClick: function() { - dis.dispatch({ - action: 'view_user', - user_id: this.props.member.userId - }); + getInitialState: function() { + return {}; }, + + onLeaveClick: function() { + var QuestionDialog = sdk.getComponent("organisms.QuestionDialog"); + + var roomId = this.props.member.roomId; + Modal.createDialog(QuestionDialog, { + title: "Leave room", + description: "Are you sure you want to leave the room?", + onFinished: function(should_leave) { + if (should_leave) { + var d = MatrixClientPeg.get().leave(roomId); + + var modal = Modal.createDialog(Loader); + + d.then(function() { + modal.close(); + dis.dispatch({action: 'view_next_room'}); + }, function(err) { + modal.close(); + Modal.createDialog(ErrorDialog, { + title: "Failed to leave room", + description: err.toString() + }); + }); + } + } + }); + } }; diff --git a/src/controllers/molecules/MessageComposer.js b/src/controllers/molecules/MessageComposer.js index f55546ae87..c2b67c7898 100644 --- a/src/controllers/molecules/MessageComposer.js +++ b/src/controllers/molecules/MessageComposer.js @@ -14,19 +14,130 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - var MatrixClientPeg = require("../../MatrixClientPeg"); +var SlashCommands = require("../../SlashCommands"); +var Modal = require("../../Modal"); +var sdk = require('../../index'); var dis = require("../../dispatcher"); +var KeyCode = { + ENTER: 13, + TAB: 9, + SHIFT: 16, + UP: 38, + DOWN: 40 +}; + +var TYPING_USER_TIMEOUT = 10000; +var TYPING_SERVER_TIMEOUT = 30000; module.exports = { + componentWillMount: function() { + this.tabStruct = { + completing: false, + original: null, + index: 0 + }; + this.sentHistory = { + // The list of typed messages. Index 0 is more recent + data: [], + // The position in data currently displayed + position: -1, + // The room the history is for. + roomId: null, + // The original text before they hit UP + originalText: null, + // The textarea element to set text to. + element: null, + + init: function(element, roomId) { + this.roomId = roomId; + this.element = element; + this.position = -1; + var storedData = window.sessionStorage.getItem( + "history_" + roomId + ); + if (storedData) { + this.data = JSON.parse(storedData); + } + if (this.roomId) { + this.setLastTextEntry(); + } + }, + + push: function(text) { + // store a message in the sent history + this.data.unshift(text); + window.sessionStorage.setItem( + "history_" + this.roomId, + JSON.stringify(this.data) + ); + // reset history position + this.position = -1; + this.originalText = null; + }, + + // move in the history. Returns true if we managed to move. + next: function(offset) { + if (this.position === -1) { + // user is going into the history, save the current line. + this.originalText = this.element.value; + } + else { + // user may have modified this line in the history; remember it. + this.data[this.position] = this.element.value; + } + + if (offset > 0 && this.position === (this.data.length - 1)) { + // we've run out of history + return false; + } + + // retrieve the next item (bounded). + var newPosition = this.position + offset; + newPosition = Math.max(-1, newPosition); + newPosition = Math.min(newPosition, this.data.length - 1); + this.position = newPosition; + + if (this.position !== -1) { + // show the message + this.element.value = this.data[this.position]; + } + else if (this.originalText !== undefined) { + // restore the original text the user was typing. + this.element.value = this.originalText; + } + return true; + }, + + saveLastTextEntry: function() { + // save the currently entered text in order to restore it later. + // NB: This isn't 'originalText' because we want to restore + // sent history items too! + var text = this.element.value; + window.sessionStorage.setItem("input_" + this.roomId, text); + }, + + setLastTextEntry: function() { + var text = window.sessionStorage.getItem("input_" + this.roomId); + if (text) { + this.element.value = text; + } + } + }; + }, + componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); + this.sentHistory.init( + this.refs.textarea.getDOMNode(), + this.props.room.roomId + ); }, componentWillUnmount: function() { dis.unregister(this.dispatcherRef); + this.sentHistory.saveLastTextEntry(); }, onAction: function(payload) { @@ -38,30 +149,265 @@ module.exports = { }, onKeyDown: function (ev) { - if (ev.keyCode == 13) { - var contentText = this.refs.textarea.getDOMNode().value; - - var content = null; - if (/^\/me /i.test(contentText)) { - content = { - msgtype: 'm.emote', - body: contentText.substring(4) - }; - } else { - content = { - msgtype: 'm.text', - body: contentText - }; + if (ev.keyCode === KeyCode.ENTER) { + var input = this.refs.textarea.getDOMNode().value; + if (input.length === 0) { + ev.preventDefault(); + return; } - - MatrixClientPeg.get().sendMessage(this.props.roomId, content).then(function() { - dis.dispatch({ - action: 'message_sent' - }); - }); - this.refs.textarea.getDOMNode().value = ''; + this.sentHistory.push(input); + this.onEnter(ev); + } + else if (ev.keyCode === KeyCode.TAB) { + var members = []; + if (this.props.room) { + members = this.props.room.getJoinedMembers(); + } + this.onTab(ev, members); + } + else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { + this.sentHistory.next( + ev.keyCode === KeyCode.UP ? 1 : -1 + ); ev.preventDefault(); } + else if (ev.keyCode !== KeyCode.SHIFT && this.tabStruct.completing) { + // they're resuming typing; reset tab complete state vars. + this.tabStruct.completing = false; + this.tabStruct.index = 0; + } + + var self = this; + setTimeout(function() { + if (self.refs.textarea && self.refs.textarea.getDOMNode().value != '') { + self.onTypingActivity(); + } else { + self.onFinishedTyping(); + } + }, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :( }, + + onEnter: function(ev) { + var contentText = this.refs.textarea.getDOMNode().value; + + var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); + if (cmd) { + ev.preventDefault(); + if (!cmd.error) { + this.refs.textarea.getDOMNode().value = ''; + } + if (cmd.promise) { + cmd.promise.done(function() { + console.log("Command success."); + }, function(err) { + console.error("Command failure: %s", err); + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Server error", + description: err.message + }); + }); + } + else if (cmd.error) { + console.error(cmd.error); + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Command error", + description: cmd.error + }); + } + return; + } + + var content = null; + if (/^\/me /i.test(contentText)) { + content = { + msgtype: 'm.emote', + body: contentText.substring(4) + }; + } else { + content = { + msgtype: 'm.text', + body: contentText + }; + } + + MatrixClientPeg.get().sendMessage(this.props.room.roomId, content).then(function() { + dis.dispatch({ + action: 'message_sent' + }); + }, function() { + dis.dispatch({ + action: 'message_send_failed' + }); + }); + this.refs.textarea.getDOMNode().value = ''; + ev.preventDefault(); + }, + + onTab: function(ev, sortedMembers) { + var textArea = this.refs.textarea.getDOMNode(); + if (!this.tabStruct.completing) { + this.tabStruct.completing = true; + this.tabStruct.index = 0; + // cache starting text + this.tabStruct.original = textArea.value; + } + + // loop in the right direction + if (ev.shiftKey) { + this.tabStruct.index --; + if (this.tabStruct.index < 0) { + // wrap to the last search match, and fix up to a real index + // value after we've matched. + this.tabStruct.index = Number.MAX_VALUE; + } + } + else { + this.tabStruct.index++; + } + + var searchIndex = 0; + var targetIndex = this.tabStruct.index; + var text = this.tabStruct.original; + + var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); + // console.log("Searched in '%s' - got %s", text, search); + if (targetIndex === 0) { // 0 is always the original text + textArea.value = text; + } + else if (search && search[1]) { + // console.log("search found: " + search+" from "+text); + var expansion; + + // FIXME: could do better than linear search here + for (var i=0; i<sortedMembers.length; i++) { + var member = sortedMembers[i]; + if (member.name && searchIndex < targetIndex) { + if (member.name.toLowerCase().indexOf(search[1].toLowerCase()) === 0) { + expansion = member.name; + searchIndex++; + } + } + } + + if (searchIndex < targetIndex) { // then search raw mxids + for (var i=0; i<sortedMembers.length; i++) { + if (searchIndex >= targetIndex) { + break; + } + var userId = sortedMembers[i].userId; + // === 1 because mxids are @username + if (userId.toLowerCase().indexOf(search[1].toLowerCase()) === 1) { + expansion = userId; + searchIndex++; + } + } + } + + if (searchIndex === targetIndex || + targetIndex === Number.MAX_VALUE) { + // xchat-style tab complete, add a colon if tab + // completing at the start of the text + if (search[0].length === text.length) { + expansion += ": "; + } + else { + expansion += " "; + } + textArea.value = text.replace( + /@?([a-zA-Z0-9_\-:\.]+)$/, expansion + ); + // cancel blink + textArea.style["background-color"] = ""; + if (targetIndex === Number.MAX_VALUE) { + // wrap the index around to the last index found + this.tabStruct.index = searchIndex; + targetIndex = searchIndex; + } + } + else { + // console.log("wrapped!"); + textArea.style["background-color"] = "#faa"; + setTimeout(function() { + textArea.style["background-color"] = ""; + }, 150); + textArea.value = text; + this.tabStruct.index = 0; + } + } + else { + this.tabStruct.index = 0; + } + // prevent the default TAB operation (typically focus shifting) + ev.preventDefault(); + }, + + onTypingActivity: function() { + this.isTyping = true; + if (!this.userTypingTimer) { + this.sendTyping(true); + } + this.startUserTypingTimer(); + this.startServerTypingTimer(); + }, + + onFinishedTyping: function() { + this.isTyping = false; + this.sendTyping(false); + this.stopUserTypingTimer(); + this.stopServerTypingTimer(); + }, + + startUserTypingTimer: function() { + this.stopUserTypingTimer(); + var self = this; + this.userTypingTimer = setTimeout(function() { + self.isTyping = false; + self.sendTyping(self.isTyping); + self.userTypingTimer = null; + }, TYPING_USER_TIMEOUT); + }, + + stopUserTypingTimer: function() { + if (this.userTypingTimer) { + clearTimeout(this.userTypingTimer); + this.userTypingTimer = null; + } + }, + + startServerTypingTimer: function() { + if (!this.serverTypingTimer) { + var self = this; + this.serverTypingTimer = setTimeout(function() { + if (self.isTyping) { + self.sendTyping(self.isTyping); + self.startServerTypingTimer(); + } + }, TYPING_SERVER_TIMEOUT / 2); + } + }, + + stopServerTypingTimer: function() { + if (this.serverTypingTimer) { + clearTimeout(this.servrTypingTimer); + this.serverTypingTimer = null; + } + }, + + sendTyping: function(isTyping) { + MatrixClientPeg.get().sendTyping( + this.props.room.roomId, + this.isTyping, TYPING_SERVER_TIMEOUT + ).done(); + }, + + refreshTyping: function() { + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + this.typingTimeout = null; + } + + } }; diff --git a/src/controllers/molecules/MessageTile.js b/src/controllers/molecules/MessageTile.js index 953e33b516..47b616e724 100644 --- a/src/controllers/molecules/MessageTile.js +++ b/src/controllers/molecules/MessageTile.js @@ -23,6 +23,28 @@ module.exports = { var actions = MatrixClientPeg.get().getPushActionsForEvent(this.props.mxEvent); if (!actions || !actions.tweaks) { return false; } return actions.tweaks.highlight; + }, + + getInitialState: function() { + return { + resending: false + }; + }, + + onResend: function() { + var self = this; + self.setState({ + resending: true + }); + MatrixClientPeg.get().resendEvent( + this.props.mxEvent, MatrixClientPeg.get().getRoom( + this.props.mxEvent.getRoomId() + ) + ).finally(function() { + self.setState({ + resending: false + }); + }) } }; diff --git a/src/controllers/molecules/RoomHeader.js b/src/controllers/molecules/RoomHeader.js index 8aa688b21e..d3afce1e49 100644 --- a/src/controllers/molecules/RoomHeader.js +++ b/src/controllers/molecules/RoomHeader.js @@ -16,6 +16,81 @@ limitations under the License. 'use strict'; -module.exports = { -}; +/* + * State vars: + * this.state.call_state = the UI state of the call (see CallHandler) + */ +var React = require('react'); +var dis = require("../../dispatcher"); +var CallHandler = require("../../CallHandler"); + +module.exports = { + propTypes: { + room: React.PropTypes.object.isRequired, + editing: React.PropTypes.bool, + onSettingsClick: React.PropTypes.func, + onSaveClick: React.PropTypes.func, + }, + + getDefaultProps: function() { + return { + editing: false, + onSettingsClick: function() {}, + onSaveClick: function() {}, + }; + }, + + componentDidMount: function() { + this.dispatcherRef = dis.register(this.onAction); + if (this.props.room) { + var call = CallHandler.getCallForRoom(this.props.room.roomId); + var callState = call ? call.call_state : "ended"; + this.setState({ + call_state: callState + }); + } + }, + + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + }, + + onAction: function(payload) { + // don't filter out payloads for room IDs other than props.room because + // we may be interested in the conf 1:1 room + if (payload.action !== 'call_state' || !payload.room_id) { + return; + } + var call = CallHandler.getCallForRoom(payload.room_id); + var callState = call ? call.call_state : "ended"; + this.setState({ + call_state: callState + }); + }, + + onVideoClick: function() { + dis.dispatch({ + action: 'place_call', + type: "video", + room_id: this.props.room.roomId + }); + }, + onVoiceClick: function() { + dis.dispatch({ + action: 'place_call', + type: "voice", + room_id: this.props.room.roomId + }); + }, + onHangupClick: function() { + var call = CallHandler.getCallForRoom(this.props.room.roomId); + if (!call) { return; } + dis.dispatch({ + 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 + }); + } +}; diff --git a/skins/base/css/molecules/MessageTile.css b/src/controllers/molecules/RoomSettings.js similarity index 70% rename from skins/base/css/molecules/MessageTile.css rename to src/controllers/molecules/RoomSettings.js index dae12e1a2b..3c0682d09a 100644 --- a/skins/base/css/molecules/MessageTile.css +++ b/src/controllers/molecules/RoomSettings.js @@ -14,22 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MessageTile { - display: table-row; -} +var React = require('react'); -.mx_MessageTile_content { - display: table-cell; -} +module.exports = { + propTypes: { + room: React.PropTypes.object.isRequired, + }, -.mx_MessageTile_sending { - color: #ddd; -} - -.mx_MessageTile_notSent { - color: #f11; -} - -.mx_MessageTile_highlight { - color: #00f; -} + getInitialState: function() { + return { + power_levels_changed: false + }; + } +}; diff --git a/src/controllers/molecules/ServerConfig.js b/src/controllers/molecules/ServerConfig.js index 3cd5156ba8..3f5dd99bb5 100644 --- a/src/controllers/molecules/ServerConfig.js +++ b/src/controllers/molecules/ServerConfig.js @@ -30,26 +30,28 @@ module.exports = { return { onHsUrlChanged: function() {}, onIsUrlChanged: function() {}, - default_hs_url: 'https://matrix.org/', - default_is_url: 'https://matrix.org/' + defaultHsUrl: 'https://matrix.org/', + defaultIsUrl: 'https://matrix.org/' }; }, getInitialState: function() { return { - hs_url: this.props.default_hs_url, - is_url: this.props.default_is_url, + hs_url: this.props.defaultHsUrl, + is_url: this.props.defaultIsUrl, } }, hsChanged: function(ev) { - this.setState({hs_url: ev.target.value}); - this.props.onHsUrlChanged(this.state.hs_url); + this.setState({hs_url: ev.target.value}, function() { + this.props.onHsUrlChanged(this.state.hs_url); + }); }, isChanged: function(ev) { - this.setState({is_url: ev.target.value}); - this.props.onIsUrlChanged(this.state.is_url); + this.setState({is_url: ev.target.value}, function() { + this.props.onIsUrlChanged(this.state.is_url); + }); }, getHsUrl: function() { diff --git a/src/controllers/molecules/UserSelector.js b/src/controllers/molecules/UserSelector.js index e7e0509690..67a56163fa 100644 --- a/src/controllers/molecules/UserSelector.js +++ b/src/controllers/molecules/UserSelector.js @@ -20,38 +20,26 @@ var React = require('react'); module.exports = { propTypes: { - initially_selected: React.PropTypes.arrayOf(React.PropTypes.string), + onChange: React.PropTypes.func, + selected_users: React.PropTypes.arrayOf(React.PropTypes.string), }, getDefaultProps: function() { return { - initially_selected: [], + onChange: function() {}, + selected: [], }; }, - getInitialState: function() { - return { - selected_users: this.props.initially_selected, - } - }, - addUser: function(user_id) { - if (this.state.selected_users.indexOf(user_id == -1)) { - this.setState({ - selected_users: this.state.selected_users.concat([user_id]), - }); + if (this.props.selected_users.indexOf(user_id == -1)) { + this.props.onChange(this.props.selected_users.concat([user_id])); } }, removeUser: function(user_id) { - this.setState({ - selected_users: this.state.selected_users.filter(function(e) { - return e != user_id; - }), - }); + this.props.onChange(this.props.selected_users.filter(function(e) { + return e != user_id; + })); }, - - getUserIds: function() { - return this.state.selected_users; - } }; diff --git a/src/controllers/molecules/voip/CallView.js b/src/controllers/molecules/voip/CallView.js new file mode 100644 index 0000000000..c8fab1fba4 --- /dev/null +++ b/src/controllers/molecules/voip/CallView.js @@ -0,0 +1,70 @@ +/* +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. +*/ + +var dis = require("../../../dispatcher"); +var CallHandler = require("../../../CallHandler"); + +/* + * State vars: + * this.state.call = MatrixCall|null + * + * Props: + * this.props.room = Room (JS SDK) + */ + +module.exports = { + + componentDidMount: function() { + this.dispatcherRef = dis.register(this.onAction); + if (this.props.room) { + this.showCall(this.props.room.roomId); + } + }, + + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + }, + + onAction: function(payload) { + // if we were given a room_id to track, don't handle anything else. + if (payload.room_id && this.props.room && + this.props.room.roomId !== payload.room_id) { + return; + } + if (payload.action !== 'call_state') { + return; + } + this.showCall(payload.room_id); + }, + + showCall: function(roomId) { + var call = CallHandler.getCall(roomId); + if (call) { + call.setLocalVideoElement(this.getVideoView().getLocalVideoElement()); + // N.B. the remote video element is used for playback for audio for voice calls + call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement()); + } + if (call && call.type === "video" && call.state !== 'ended') { + this.getVideoView().getLocalVideoElement().style.display = "initial"; + this.getVideoView().getRemoteVideoElement().style.display = "initial"; + } + else { + this.getVideoView().getLocalVideoElement().style.display = "none"; + this.getVideoView().getRemoteVideoElement().style.display = "none"; + } + } +}; + diff --git a/src/controllers/molecules/voip/IncomingCallBox.js b/src/controllers/molecules/voip/IncomingCallBox.js new file mode 100644 index 0000000000..9ecced56c5 --- /dev/null +++ b/src/controllers/molecules/voip/IncomingCallBox.js @@ -0,0 +1,73 @@ +/* +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. +*/ + +var dis = require("../../../dispatcher"); +var CallHandler = require("../../../CallHandler"); + +module.exports = { + componentDidMount: function() { + this.dispatcherRef = dis.register(this.onAction); + }, + + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + }, + + getInitialState: function() { + return { + incomingCall: null + } + }, + + onAction: function(payload) { + if (payload.action !== 'call_state') { + return; + } + var call = CallHandler.getCall(payload.room_id); + if (!call || call.call_state !== 'ringing') { + this.setState({ + incomingCall: null, + }); + this.getRingAudio().pause(); + return; + } + if (call.call_state === "ringing") { + this.getRingAudio().load(); + this.getRingAudio().play(); + } + else { + this.getRingAudio().pause(); + } + + this.setState({ + incomingCall: call + }); + }, + + onAnswerClick: function() { + dis.dispatch({ + action: 'answer', + room_id: this.state.incomingCall.roomId + }); + }, + onRejectClick: function() { + dis.dispatch({ + action: 'hangup', + room_id: this.state.incomingCall.roomId + }); + } +}; + diff --git a/skins/base/css/molecules/MNoticeTile.css b/src/controllers/molecules/voip/VideoView.js similarity index 96% rename from skins/base/css/molecules/MNoticeTile.css rename to src/controllers/molecules/voip/VideoView.js index cac13e9b89..3d34134daa 100644 --- a/skins/base/css/molecules/MNoticeTile.css +++ b/src/controllers/molecules/voip/VideoView.js @@ -14,5 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MNoticeTile { -} +module.exports = { +}; + diff --git a/src/controllers/organisms/CreateRoom.js b/src/controllers/organisms/CreateRoom.js index c2112ce58f..f6404eb231 100644 --- a/src/controllers/organisms/CreateRoom.js +++ b/src/controllers/organisms/CreateRoom.js @@ -18,6 +18,9 @@ limitations under the License. var React = require("react"); var MatrixClientPeg = require("../../MatrixClientPeg"); +var PresetValues = require('../atoms/create_room/Presets').Presets; +var q = require('q'); +var encryption = require("../../encryption"); module.exports = { propTypes: { @@ -41,25 +44,52 @@ module.exports = { return { phase: this.phases.CONFIG, error_string: "", + is_private: true, + share_history: false, + default_preset: PresetValues.PrivateChat, + topic: '', + room_name: '', + invited_users: [], }; }, onCreateRoom: function() { var options = {}; - var room_name = this.getName(); - if (room_name) { - options.name = room_name; + if (this.state.room_name) { + options.name = this.state.room_name; } - var preset = this.getPreset(); - if (preset) { - options.preset = preset; + if (this.state.topic) { + options.topic = this.state.topic; } - var invited_users = this.getInvitedUsers(); - if (invited_users) { - options.invite = invited_users; + if (this.state.preset) { + if (this.state.preset != PresetValues.Custom) { + options.preset = this.state.preset; + } else { + options.initial_state = [ + { + type: "m.room.join_rules", + content: { + "join_rules": this.state.is_private ? "invite" : "public" + } + }, + { + type: "m.room.history_visibility", + content: { + "history_visibility": this.state.share_history ? "shared" : "invited" + } + }, + ]; + } + } + + options.invite = this.state.invited_users; + + var alias = this.getAliasLocalpart(); + if (alias) { + options.room_alias_name = alias; } var cli = MatrixClientPeg.get(); @@ -69,7 +99,20 @@ module.exports = { return; } - var deferred = MatrixClientPeg.get().createRoom(options); + var deferred = cli.createRoom(options); + + var response; + + if (this.state.encrypt) { + deferred = deferred.then(function(res) { + response = res; + return encryption.enableEncryption( + cli, response.roomId, options.invite + ); + }).then(function() { + return q(response) } + ); + } this.setState({ phase: this.phases.CREATING, @@ -77,11 +120,11 @@ module.exports = { var self = this; - deferred.then(function () { + deferred.then(function (resp) { self.setState({ phase: self.phases.CREATED, }); - self.props.onRoomCreated(); + self.props.onRoomCreated(resp.room_id); }, function(err) { self.setState({ phase: self.phases.ERROR, diff --git a/skins/base/views/molecules/RoomHeader.js b/src/controllers/organisms/ErrorDialog.js similarity index 54% rename from skins/base/views/molecules/RoomHeader.js rename to src/controllers/organisms/ErrorDialog.js index b5296f4e82..6b7c35b3f2 100644 --- a/skins/base/views/molecules/RoomHeader.js +++ b/src/controllers/organisms/ErrorDialog.js @@ -14,22 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +var React = require("react"); -var React = require('react'); - -var RoomHeaderController = require("../../../../src/controllers/molecules/RoomHeader"); - -module.exports = React.createClass({ - displayName: 'RoomHeader', - mixins: [RoomHeaderController], - - render: function() { - return ( - <div className="mx_RoomHeader"> - {this.props.room.name} - </div> - ); +module.exports = { + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.string, + button: React.PropTypes.string, + focus: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired, }, -}); + getDefaultProps: function() { + return { + title: "Error", + description: "An error has occurred.", + button: "OK", + focus: true, + }; + }, +}; diff --git a/skins/base/css/molecules/MTextTile.css b/src/controllers/organisms/LogoutPrompt.js similarity index 62% rename from skins/base/css/molecules/MTextTile.css rename to src/controllers/organisms/LogoutPrompt.js index 5b117e41b8..5e5011ea97 100644 --- a/skins/base/css/molecules/MTextTile.css +++ b/src/controllers/organisms/LogoutPrompt.js @@ -14,7 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_MTextTile { - white-space: pre-wrap; -} +var dis = require("../../dispatcher"); + +module.exports = { + logOut: function() { + dis.dispatch({action: 'logout'}); + if (this.props.onFinished) { + this.props.onFinished(); + } + }, + + cancelPrompt: function() { + if (this.props.onFinished) { + this.props.onFinished(); + } + } +}; diff --git a/src/controllers/organisms/MemberList.js b/src/controllers/organisms/MemberList.js index a511816d53..bf61a80048 100644 --- a/src/controllers/organisms/MemberList.js +++ b/src/controllers/organisms/MemberList.js @@ -14,10 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -var React = require("react"); var MatrixClientPeg = require("../../MatrixClientPeg"); +var Modal = require("../../Modal"); +var sdk = require('../../index'); var INITIAL_LOAD_NUM_MEMBERS = 50; @@ -32,39 +31,137 @@ module.exports = { componentWillMount: function() { var cli = MatrixClientPeg.get(); cli.on("RoomState.members", this.onRoomStateMember); + cli.on("Room", this.onRoom); // invites }, componentWillUnmount: function() { if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().removeListener("User.presence", this.userPresenceFn); } }, componentDidMount: function() { - var that = this; + var self = this; + + // Lazy-load in more than the first N members setTimeout(function() { - if (!that.isMounted()) return; - that.setState({ - memberDict: that.roomMembers() + if (!self.isMounted()) return; + self.setState({ + memberDict: self.roomMembers() }); }, 50); - }, + // 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. + function updateUserState(event, user) { + // XXX: evil hack to track the age of this presence info. + // this should be removed once syjs-28 is resolved in the JS SDK itself. + user.lastPresenceTs = Date.now(); + + var tile = self.refs[user.userId]; + + console.log("presence event " + JSON.stringify(event) + " user = " + user + " tile = " + tile); + + if (tile) { + self._updateList(); // reorder the membership list + self.forceUpdate(); // FIXME: is the a more efficient way of reordering with react? + // XXX: do we even need to do this, or is it done by the main list? + tile.forceUpdate(); + } + } + // FIXME: we should probably also reset 'lastActiveAgo' to zero whenever + // we see a typing notif from a user, as we don't get presence updates for those. + MatrixClientPeg.get().on("User.presence", updateUserState); + this.userPresenceFn = updateUserState; + }, // Remember to set 'key' on a MemberList to the ID of the room it's for /*componentWillReceiveProps: function(newProps) { },*/ + onRoom: function(room) { + if (room.roomId !== this.props.roomId) { + return; + } + // We listen for room events because when we accept an invite + // we need to wait till the room is fully populated with state + // before refreshing the member list else we get a stale list. + this._updateList(); + }, + onRoomStateMember: function(ev, state, member) { + this._updateList(); + }, + + _updateList: function() { var members = this.roomMembers(); this.setState({ memberDict: members }); }, + onInvite: function(inputText) { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var self = this; + // sanity check the input + inputText = inputText.trim(); // react requires es5-shim so we know trim() exists + if (inputText[0] !== '@' || inputText.indexOf(":") === -1) { + console.error("Bad user ID to invite: %s", inputText); + Modal.createDialog(ErrorDialog, { + title: "Invite Error", + description: "Malformed user ID. Should look like '@localpart:domain'" + }); + return; + } + self.setState({ + inviting: true + }); + console.log("Invite %s to %s", inputText, this.props.roomId); + MatrixClientPeg.get().invite(this.props.roomId, inputText).done( + function(res) { + console.log("Invited"); + self.setState({ + inviting: false + }); + }, function(err) { + console.error("Failed to invite: %s", JSON.stringify(err)); + Modal.createDialog(ErrorDialog, { + title: "Server error whilst inviting", + description: err.message + }); + self.setState({ + inviting: false + }); + }); + }, + roomMembers: function(limit) { + if (!this.props.roomId) return {}; var cli = MatrixClientPeg.get(); - var all_members = cli.getRoom(this.props.roomId).currentState.members; + var room = cli.getRoom(this.props.roomId); + if (!room) return {}; + var all_members = room.currentState.members; var all_user_ids = Object.keys(all_members); + + // XXX: evil hack until SYJS-28 is fixed + all_user_ids.map(function(userId) { + if (all_members[userId].user && !all_members[userId].user.lastPresenceTs) { + all_members[userId].user.lastPresenceTs = Date.now(); + } + }); + + all_user_ids.sort(function(userIdA, userIdB) { + var userA = all_members[userIdA].user; + var userB = all_members[userIdB].user; + + var latA = userA ? (userA.lastPresenceTs - (userA.lastActiveAgo || userA.lastPresenceTs)) : 0; + var latB = userB ? (userB.lastPresenceTs - (userB.lastActiveAgo || userB.lastPresenceTs)) : 0; + + return latB - latA; + }); + var to_display = {}; var count = 0; for (var i = 0; i < all_user_ids.length && (limit === undefined || count < limit); ++i) { @@ -72,6 +169,8 @@ module.exports = { var m = all_members[user_id]; if (m.membership == 'join' || m.membership == 'invite') { + // XXX: this is evil, and relies on the fact that Object.keys() iterates + // over the keys of a dict in insertion order (if those keys are strings) to_display[user_id] = m; ++count; } diff --git a/src/controllers/organisms/Notifier.js b/src/controllers/organisms/Notifier.js index 63e937780d..8fb62abe40 100644 --- a/src/controllers/organisms/Notifier.js +++ b/src/controllers/organisms/Notifier.js @@ -17,11 +17,21 @@ limitations under the License. 'use strict'; var MatrixClientPeg = require("../../MatrixClientPeg"); +var dis = require("../../dispatcher"); + +/* + * Dispatches: + * { + * action: "notifier_enabled", + * value: boolean + * } + */ module.exports = { start: function() { this.boundOnRoomTimeline = this.onRoomTimeline.bind(this); MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); + this.state = { 'toolbarHidden' : false }; }, stop: function() { @@ -30,12 +40,80 @@ module.exports = { } }, + supportsDesktopNotifications: function() { + return !!global.Notification; + }, + + havePermission: function() { + if (!this.supportsDesktopNotifications()) return false; + return global.Notification.permission == 'granted'; + }, + + setEnabled: function(enable, callback) { + if(enable) { + if (!this.havePermission()) { + global.Notification.requestPermission(function() { + if (callback) { + callback(); + dis.dispatch({ + action: "notifier_enabled", + value: true + }); + } + }); + } + + if (!global.localStorage) return; + global.localStorage.setItem('notifications_enabled', 'true'); + + if (this.havePermission) { + dis.dispatch({ + action: "notifier_enabled", + value: true + }); + } + } + else { + if (!global.localStorage) return; + global.localStorage.setItem('notifications_enabled', 'false'); + dis.dispatch({ + action: "notifier_enabled", + value: false + }); + } + + this.setToolbarHidden(false); + }, + + isEnabled: function() { + if (!this.havePermission()) return false; + + if (!global.localStorage) return true; + + var enabled = global.localStorage.getItem('notifications_enabled'); + if (enabled === null) return true; + return enabled === 'true'; + }, + + setToolbarHidden: function(hidden) { + this.state.toolbarHidden = hidden; + dis.dispatch({ + action: "notifier_enabled", + value: this.isEnabled() + }); + }, + + isToolbarHidden: function() { + return this.state.toolbarHidden; + }, + onRoomTimeline: function(ev, room, toStartOfTimeline) { if (toStartOfTimeline) return; if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; - var enabled = global.localStorage.getItem('notifications_enabled'); - if (enabled === 'false') return; + if (!this.isEnabled()) { + return; + } var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { diff --git a/skins/base/views/molecules/MessageComposer.js b/src/controllers/organisms/QuestionDialog.js similarity index 55% rename from skins/base/views/molecules/MessageComposer.js rename to src/controllers/organisms/QuestionDialog.js index 89c426cb2b..30891b839d 100644 --- a/skins/base/views/molecules/MessageComposer.js +++ b/src/controllers/organisms/QuestionDialog.js @@ -14,22 +14,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +var React = require("react"); -var React = require('react'); - -var MessageComposerController = require("../../../../src/controllers/molecules/MessageComposer"); - -module.exports = React.createClass({ - displayName: 'MessageComposer', - mixins: [MessageComposerController], - - render: function() { - return ( - <div className="mx_MessageComposer"> - <textarea ref="textarea" onKeyDown={this.onKeyDown} /> - </div> - ); +module.exports = { + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.string, + button: React.PropTypes.string, + focus: React.PropTypes.bool, + onFinished: React.PropTypes.func.isRequired, }, -}); + getDefaultProps: function() { + return { + title: "", + description: "", + button: "OK", + focus: true, + }; + }, +}; diff --git a/src/controllers/organisms/RoomList.js b/src/controllers/organisms/RoomList.js index 03e18547b6..be502e3b88 100644 --- a/src/controllers/organisms/RoomList.js +++ b/src/controllers/organisms/RoomList.js @@ -20,9 +20,7 @@ var React = require("react"); var MatrixClientPeg = require("../../MatrixClientPeg"); var RoomListSorter = require("../../RoomListSorter"); -var ComponentBroker = require('../../ComponentBroker'); - -var RoomTile = ComponentBroker.get("molecules/RoomTile"); +var sdk = require('../../index'); module.exports = { componentWillMount: function() { @@ -73,13 +71,11 @@ module.exports = { if (actions && actions.tweaks && actions.tweaks.highlight) { hl = 2; } - if (actions.notify) { - // obviously this won't deep copy but this shouldn't be necessary - var amap = this.state.activityMap; - amap[room.roomId] = Math.max(amap[room.roomId] || 0, hl); + // obviously this won't deep copy but this shouldn't be necessary + var amap = this.state.activityMap; + amap[room.roomId] = Math.max(amap[room.roomId] || 0, hl); - newState.activityMap = amap; - } + newState.activityMap = amap; } this.setState(newState); }, @@ -96,23 +92,28 @@ module.exports = { }, getRoomList: function() { - return RoomListSorter.mostRecentActivityFirst(MatrixClientPeg.get().getRooms()); + return RoomListSorter.mostRecentActivityFirst( + MatrixClientPeg.get().getRooms().filter(function(room) { + var member = room.getMember(MatrixClientPeg.get().credentials.userId); + return member && (member.membership == "join" || member.membership == "invite"); + }) + ); }, makeRoomTiles: function() { - var that = this; + var RoomTile = sdk.getComponent('molecules.RoomTile'); + var self = this; return this.state.roomList.map(function(room) { - var selected = room.roomId == that.props.selectedRoom; + var selected = room.roomId == self.props.selectedRoom; return ( <RoomTile room={room} key={room.roomId} selected={selected} - unread={that.state.activityMap[room.roomId] === 1} - highlight={that.state.activityMap[room.roomId] === 2} + unread={self.state.activityMap[room.roomId] === 1} + highlight={self.state.activityMap[room.roomId] === 2} /> ); }); }, }; - diff --git a/src/controllers/organisms/RoomView.js b/src/controllers/organisms/RoomView.js index 10b375cdad..ea84746bab 100644 --- a/src/controllers/organisms/RoomView.js +++ b/src/controllers/organisms/RoomView.js @@ -14,36 +14,36 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - var MatrixClientPeg = require("../../MatrixClientPeg"); var React = require("react"); var q = require("q"); var ContentMessages = require("../../ContentMessages"); +var WhoIsTyping = require("../../WhoIsTyping"); +var Modal = require("../../Modal"); +var sdk = require('../../index'); var dis = require("../../dispatcher"); var PAGINATE_SIZE = 20; -var INITIAL_SIZE = 100; - -var ComponentBroker = require('../../ComponentBroker'); - -var tileTypes = { - 'm.room.message': ComponentBroker.get('molecules/MessageTile'), - 'm.room.member': ComponentBroker.get('molecules/MRoomMemberTile') -}; +var INITIAL_SIZE = 20; module.exports = { getInitialState: function() { return { room: this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null, - messageCap: INITIAL_SIZE + messageCap: INITIAL_SIZE, + editingRoomSettings: false, + uploadingRoomSettings: false, + numUnreadMessages: 0, + draggingFile: false, } }, componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); + MatrixClientPeg.get().on("Room.name", this.onRoomName); + MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); this.atBottom = true; }, @@ -51,19 +51,40 @@ module.exports = { if (this.refs.messageWrapper) { var messageWrapper = this.refs.messageWrapper.getDOMNode(); messageWrapper.removeEventListener('drop', this.onDrop); + messageWrapper.removeEventListener('dragover', this.onDragOver); + messageWrapper.removeEventListener('dragleave', this.onDragLeaveOrEnd); + messageWrapper.removeEventListener('dragend', this.onDragLeaveOrEnd); } dis.unregister(this.dispatcherRef); if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); + MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); + MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); } }, onAction: function(payload) { switch (payload.action) { + case 'message_send_failed': case 'message_sent': this.setState({ room: MatrixClientPeg.get().getRoom(this.props.roomId) }); + this.forceUpdate(); + break; + case 'notifier_enabled': + this.forceUpdate(); + break; + case 'call_state': + if (this.props.roomId !== payload.room_id) { + break; + } + // scroll to bottom + var messageWrapper = this.refs.messageWrapper; + if (messageWrapper) { + messageWrapper = messageWrapper.getDOMNode(); + messageWrapper.scrollTop = messageWrapper.scrollHeight; + } break; } }, @@ -87,13 +108,31 @@ module.exports = { // we'll only be showing a spinner. if (this.state.joining) return; if (room.roomId != this.props.roomId) return; - + if (this.refs.messageWrapper) { var messageWrapper = this.refs.messageWrapper.getDOMNode(); - this.atBottom = messageWrapper.scrollHeight - messageWrapper.scrollTop <= messageWrapper.clientHeight; + this.atBottom = ( + messageWrapper.scrollHeight - messageWrapper.scrollTop <= + (messageWrapper.clientHeight + 150) + ); } + + var currentUnread = this.state.numUnreadMessages; + if (!toStartOfTimeline && + (ev.getSender() !== MatrixClientPeg.get().credentials.userId)) { + // update unread count when scrolled up + if (this.atBottom) { + currentUnread = 0; + } + else { + currentUnread += 1; + } + } + + this.setState({ - room: MatrixClientPeg.get().getRoom(this.props.roomId) + room: MatrixClientPeg.get().getRoom(this.props.roomId), + numUnreadMessages: currentUnread }); if (toStartOfTimeline && !this.state.paginating) { @@ -101,12 +140,26 @@ module.exports = { } }, + onRoomName: function(room) { + if (room.roomId == this.props.roomId) { + this.setState({ + room: room + }); + } + }, + + onRoomMemberTyping: function(ev, member) { + this.forceUpdate(); + }, + componentDidMount: function() { if (this.refs.messageWrapper) { var messageWrapper = this.refs.messageWrapper.getDOMNode(); messageWrapper.addEventListener('drop', this.onDrop); messageWrapper.addEventListener('dragover', this.onDragOver); + messageWrapper.addEventListener('dragleave', this.onDragLeaveOrEnd); + messageWrapper.addEventListener('dragend', this.onDragLeaveOrEnd); messageWrapper.scrollTop = messageWrapper.scrollHeight; @@ -128,10 +181,14 @@ module.exports = { } } else if (this.atBottom) { messageWrapper.scrollTop = messageWrapper.scrollHeight; + if (this.state.numUnreadMessages !== 0) { + this.setState({numUnreadMessages: 0}); + } } }, fillSpace: function() { + if (!this.refs.messageWrapper) return; var messageWrapper = this.refs.messageWrapper.getDOMNode(); if (messageWrapper.scrollTop < messageWrapper.clientHeight && this.state.room.oldState.paginationToken) { this.setState({paginating: true}); @@ -146,12 +203,12 @@ module.exports = { this.waiting_for_paginate = true; var cap = this.state.messageCap + PAGINATE_SIZE; this.setState({messageCap: cap, paginating: true}); - var that = this; + var self = this; MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(function() { - that.waiting_for_paginate = false; - if (that.isMounted()) { - that.setState({ - room: MatrixClientPeg.get().getRoom(that.props.roomId) + self.waiting_for_paginate = false; + if (self.isMounted()) { + self.setState({ + room: MatrixClientPeg.get().getRoom(self.props.roomId) }); } // wait and set paginating to false when the component updates @@ -164,14 +221,14 @@ module.exports = { }, onJoinButtonClicked: function(ev) { - var that = this; + var self = this; MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() { - that.setState({ + self.setState({ joining: false, - room: MatrixClientPeg.get().getRoom(that.props.roomId) + room: MatrixClientPeg.get().getRoom(self.props.roomId) }); }, function(error) { - that.setState({ + self.setState({ joining: false, joinError: error }); @@ -184,7 +241,11 @@ module.exports = { onMessageListScroll: function(ev) { if (this.refs.messageWrapper) { var messageWrapper = this.refs.messageWrapper.getDOMNode(); + var wasAtBottom = this.atBottom; this.atBottom = messageWrapper.scrollHeight - messageWrapper.scrollTop <= messageWrapper.clientHeight; + if (this.atBottom && !wasAtBottom) { + this.forceUpdate(); // remove unread msg count + } } if (!this.state.paginating) this.fillSpace(); }, @@ -198,6 +259,7 @@ module.exports = { var items = ev.dataTransfer.items; if (items.length == 1) { if (items[0].kind == 'file') { + this.setState({ draggingFile : true }); ev.dataTransfer.dropEffect = 'copy'; } } @@ -206,33 +268,178 @@ module.exports = { onDrop: function(ev) { ev.stopPropagation(); ev.preventDefault(); + this.setState({ draggingFile : false }); var files = ev.dataTransfer.files; - if (files.length == 1) { - ContentMessages.sendContentToRoom( - files[0], this.props.roomId, MatrixClientPeg.get() - ).progress(function(ev) { - //console.log("Upload: "+ev.loaded+" / "+ev.total); - }).done(undefined, function() { - // display error message - }); + this.uploadFile(files[0]); } }, + onDragLeaveOrEnd: function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.setState({ draggingFile : false }); + }, + + uploadFile: function(file) { + this.setState({ + upload: { + fileName: file.name, + uploadedBytes: 0, + totalBytes: file.size + } + }); + var self = this; + ContentMessages.sendContentToRoom( + file, this.props.roomId, MatrixClientPeg.get() + ).progress(function(ev) { + //console.log("Upload: "+ev.loaded+" / "+ev.total); + self.setState({ + upload: { + fileName: file.name, + uploadedBytes: ev.loaded, + totalBytes: ev.total + } + }); + }).finally(function() { + self.setState({ + upload: undefined + }); + }).done(undefined, function() { + // display error message + }); + }, + + getWhoIsTypingString: function() { + return WhoIsTyping.whoIsTypingString(this.state.room); + }, + getEventTiles: function() { + var tileTypes = { + 'm.room.message': sdk.getComponent('molecules.MessageTile'), + 'm.room.member' : sdk.getComponent('molecules.EventAsTextTile'), + 'm.call.invite' : sdk.getComponent('molecules.EventAsTextTile'), + 'm.call.answer' : sdk.getComponent('molecules.EventAsTextTile'), + 'm.call.hangup' : sdk.getComponent('molecules.EventAsTextTile'), + 'm.room.topic' : sdk.getComponent('molecules.EventAsTextTile'), + }; + var ret = []; var count = 0; for (var i = this.state.room.timeline.length-1; i >= 0 && count < this.state.messageCap; --i) { var mxEv = this.state.room.timeline[i]; var TileType = tileTypes[mxEv.getType()]; + var continuation = false; + var last = false; + if (i == this.state.room.timeline.length - 1) { + last = true; + } + if (i > 0 && count < this.state.messageCap - 1) { + if (this.state.room.timeline[i].sender && + this.state.room.timeline[i - 1].sender && + (this.state.room.timeline[i].sender.userId === + this.state.room.timeline[i - 1].sender.userId) && + (this.state.room.timeline[i].getType() == + this.state.room.timeline[i - 1].getType()) + ) + { + continuation = true; + } + } if (!TileType) continue; ret.unshift( - <TileType key={mxEv.getId()} mxEvent={mxEv} /> + <li key={mxEv.getId()}><TileType mxEvent={mxEv} continuation={continuation} last={last}/></li> ); ++count; } return ret; + }, + + uploadNewState: function(new_name, new_topic, new_join_rule, new_history_visibility, new_power_levels) { + var old_name = this.state.room.name; + + var old_topic = this.state.room.currentState.getStateEvents('m.room.topic', ''); + if (old_topic) { + old_topic = old_topic.getContent().topic; + } else { + old_topic = ""; + } + + var old_join_rule = this.state.room.currentState.getStateEvents('m.room.join_rules', ''); + if (old_join_rule) { + old_join_rule = old_join_rule.getContent().join_rule; + } else { + old_join_rule = "invite"; + } + + var old_history_visibility = this.state.room.currentState.getStateEvents('m.room.history_visibility', ''); + if (old_history_visibility) { + old_history_visibility = old_history_visibility.getContent().history_visibility; + } else { + old_history_visibility = "shared"; + } + + var deferreds = []; + + if (old_name != new_name && new_name != undefined && new_name) { + deferreds.push( + MatrixClientPeg.get().setRoomName(this.state.room.roomId, new_name) + ); + } + + if (old_topic != new_topic && new_topic != undefined) { + deferreds.push( + MatrixClientPeg.get().setRoomTopic(this.state.room.roomId, new_topic) + ); + } + + if (old_join_rule != new_join_rule && new_join_rule != undefined) { + deferreds.push( + MatrixClientPeg.get().sendStateEvent( + this.state.room.roomId, "m.room.join_rules", { + join_rule: new_join_rule, + }, "" + ) + ); + } + + if (old_history_visibility != new_history_visibility && new_history_visibility != undefined) { + deferreds.push( + MatrixClientPeg.get().sendStateEvent( + this.state.room.roomId, "m.room.history_visibility", { + history_visibility: new_history_visibility, + }, "" + ) + ); + } + + if (new_power_levels) { + deferreds.push( + MatrixClientPeg.get().sendStateEvent( + this.state.room.roomId, "m.room.power_levels", new_power_levels, "" + ) + ); + } + + if (deferreds.length) { + var self = this; + q.all(deferreds).fail(function(err) { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to set state", + description: err.toString() + }); + }).finally(function() { + self.setState({ + uploadingRoomSettings: false, + }); + }); + } else { + this.setState({ + editingRoomSettings: false, + uploadingRoomSettings: false, + }); + } } }; - diff --git a/src/controllers/organisms/UserSettings.js b/src/controllers/organisms/UserSettings.js new file mode 100644 index 0000000000..60d643c22b --- /dev/null +++ b/src/controllers/organisms/UserSettings.js @@ -0,0 +1,66 @@ +/* +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. +*/ + +var MatrixClientPeg = require("../../MatrixClientPeg"); +var q = require('q'); +var version = require('../../../package.json').version; + +module.exports = { + Phases: { + Loading: "loading", + Display: "display", + }, + + getInitialState: function() { + return { + displayName: null, + avatarUrl: null, + threePids: [], + clientVersion: version, + phase: this.Phases.Loading, + }; + }, + + changeDisplayname: function(new_displayname) { + if (this.state.displayName == new_displayname) return; + + var self = this; + return MatrixClientPeg.get().setDisplayName(new_displayname).then( + function() { self.setState({displayName: new_displayname}); }, + function(err) { console.err(err); } + ); + }, + + componentWillMount: function() { + var self = this; + var cli = MatrixClientPeg.get(); + + var profile_d = cli.getProfileInfo(cli.credentials.userId); + var threepid_d = cli.getThreePids(); + + q.all([profile_d, threepid_d]).then( + function(resps) { + self.setState({ + displayName: resps[0].displayname, + avatarUrl: resps[0].avatar_url, + threepids: resps[1].threepids, + phase: self.Phases.Display, + }); + }, + function(err) { console.err(err); } + ); + } +} diff --git a/src/controllers/pages/MatrixChat.js b/src/controllers/pages/MatrixChat.js index 7c4e35c552..5c40d93ce4 100644 --- a/src/controllers/pages/MatrixChat.js +++ b/src/controllers/pages/MatrixChat.js @@ -14,26 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -// should be atomised -var Loader = require("react-loader"); - var MatrixClientPeg = require("../../MatrixClientPeg"); var RoomListSorter = require("../../RoomListSorter"); - +var Presence = require("../../Presence"); var dis = require("../../dispatcher"); +var q = require("q"); -var ComponentBroker = require('../../ComponentBroker'); - -var Notifier = ComponentBroker.get('organisms/Notifier'); +var sdk = require('../../index'); +var MatrixTools = require('../../MatrixTools'); module.exports = { + PageTypes: { + RoomView: "room_view", + UserSettings: "user_settings", + CreateRoom: "create_room", + RoomDirectory: "room_directory", + }, + + AuxPanel: { + RoomSettings: "room_settings", + }, + getInitialState: function() { - return { + var s = { logged_in: !!(MatrixClientPeg.get() && MatrixClientPeg.get().credentials), - ready: false + ready: false, }; + if (s.logged_in) { + if (MatrixClientPeg.get().getRooms().length) { + s.page_type = this.PageTypes.RoomView; + } else { + s.page_type = this.PageTypes.RoomDirectory; + } + } + return s; }, componentDidMount: function() { @@ -54,6 +68,7 @@ module.exports = { componentWillUnmount: function() { dis.unregister(this.dispatcherRef); document.removeEventListener("keydown", this.onKeyDown); + window.removeEventListener("focus", this.onFocus); }, componentDidUpdate: function() { @@ -65,6 +80,7 @@ module.exports = { onAction: function(payload) { var roomIndexDelta = 1; + var Notifier = sdk.getComponent('organisms.Notifier'); switch (payload.action) { case 'logout': @@ -76,8 +92,11 @@ module.exports = { window.localStorage.clear(); } Notifier.stop(); + Presence.stop(); + MatrixClientPeg.get().stopClient(); MatrixClientPeg.get().removeAllListeners(); - MatrixClientPeg.replace(null); + MatrixClientPeg.unset(); + this.notifyNewScreen(''); break; case 'start_registration': if (this.state.logged_in) return; @@ -110,8 +129,23 @@ module.exports = { case 'view_room': this.focusComposer = true; this.setState({ - currentRoom: payload.room_id + currentRoom: payload.room_id, + page_type: this.PageTypes.RoomView, }); + if (this.sdkReady) { + // if the SDK is not ready yet, remember what room + // we're supposed to be on but don't notify about + // 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 = payload.room_id; + var room = MatrixClientPeg.get().getRoom(payload.room_id); + if (room) { + var theAlias = MatrixTools.getCanonicalAliasForRoom(room); + if (theAlias) presentedId = theAlias; + } + this.notifyNewScreen('room/'+presentedId); + } break; case 'view_prev_room': roomIndexDelta = -1; @@ -127,9 +161,43 @@ module.exports = { } } roomIndex = (roomIndex + roomIndexDelta) % allRooms.length; + if (roomIndex < 0) roomIndex = allRooms.length - 1; + this.focusComposer = true; this.setState({ currentRoom: allRooms[roomIndex].roomId }); + this.notifyNewScreen('room/'+allRooms[roomIndex].roomId); + break; + case 'view_indexed_room': + var allRooms = RoomListSorter.mostRecentActivityFirst( + MatrixClientPeg.get().getRooms() + ); + var roomIndex = payload.roomIndex; + if (allRooms[roomIndex]) { + this.focusComposer = true; + this.setState({ + currentRoom: allRooms[roomIndex].roomId + }); + this.notifyNewScreen('room/'+allRooms[roomIndex].roomId); + } + break; + case 'view_user_settings': + this.setState({ + page_type: this.PageTypes.UserSettings, + }); + break; + case 'view_create_room': + this.setState({ + page_type: this.PageTypes.CreateRoom, + }); + break; + case 'view_room_directory': + this.setState({ + page_type: this.PageTypes.RoomDirectory, + }); + break; + case 'notifier_enabled': + this.forceUpdate(); break; } }, @@ -144,32 +212,83 @@ module.exports = { }, startMatrixClient: function() { + var Notifier = sdk.getComponent('organisms.Notifier'); var cli = MatrixClientPeg.get(); - var that = this; + var self = this; cli.on('syncComplete', function() { - var firstRoom = null; - if (cli.getRooms() && cli.getRooms().length) { - firstRoom = RoomListSorter.mostRecentActivityFirst( - cli.getRooms() - )[0].roomId; + self.sdkReady = true; + + var defer = q.defer(); + if (self.starting_room_alias) { + MatrixClientPeg.get().getRoomIdForAlias(self.starting_room_alias).done(function(result) { + self.setState({currentRoom: result.room_id}); + defer.resolve(); + }, function(error) { + defer.resolve(); + }); + } else { + defer.resolve(); } - that.setState({ready: true, currentRoom: firstRoom}); - dis.dispatch({action: 'focus_composer'}); + + defer.promise.done(function() { + if (!self.state.currentRoom) { + var firstRoom = null; + if (cli.getRooms() && cli.getRooms().length) { + firstRoom = RoomListSorter.mostRecentActivityFirst( + cli.getRooms() + )[0].roomId; + self.setState({ready: true, currentRoom: firstRoom, page_type: self.PageTypes.RoomView}); + } else { + self.setState({ready: true, page_type: self.PageTypes.RoomDirectory}); + } + } else { + self.setState({ready: true, page_type: self.PageTypes.RoomView}); + } + + // we notifyNewScreen now because now the room will actually be displayed, + // and (mostly) now we can get the correct alias. + var presentedId = self.state.currentRoom; + var room = MatrixClientPeg.get().getRoom(self.state.currentRoom); + if (room) { + var theAlias = MatrixTools.getCanonicalAliasForRoom(room); + if (theAlias) presentedId = theAlias; + } + self.notifyNewScreen('room/'+presentedId); + dis.dispatch({action: 'focus_composer'}); + }); + }); + cli.on('Call.incoming', function(call) { + dis.dispatch({ + action: 'incoming_call', + call: call + }); }); Notifier.start(); + Presence.start(); cli.startClient(); }, onKeyDown: function(ev) { if (ev.altKey) { + if (ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) { + dis.dispatch({ + action: 'view_indexed_room', + roomIndex: ev.keyCode - 49, + }); + ev.stopPropagation(); + ev.preventDefault(); + return; + } switch (ev.keyCode) { case 38: dis.dispatch({action: 'view_prev_room'}); ev.stopPropagation(); + ev.preventDefault(); break; case 40: dis.dispatch({action: 'view_next_room'}); ev.stopPropagation(); + ev.preventDefault(); break; } } @@ -190,6 +309,16 @@ module.exports = { action: 'start_login', params: params }); + } else if (screen.indexOf('room/') == 0) { + var roomString = screen.split('/')[1]; + if (roomString[0] == '#') { + this.starting_room_alias = roomString; + } else { + dis.dispatch({ + action: 'view_room', + room_id: roomString + }); + } } }, @@ -199,4 +328,3 @@ module.exports = { } } }; - diff --git a/src/controllers/templates/Login.js b/src/controllers/templates/Login.js index 51c2543b8d..c14ed28c4a 100644 --- a/src/controllers/templates/Login.js +++ b/src/controllers/templates/Login.js @@ -16,14 +16,9 @@ limitations under the License. 'use strict'; -var React = require('react'); - var MatrixClientPeg = require("../../MatrixClientPeg"); -var Matrix = require("matrix-js-sdk"); var dis = require("../../dispatcher"); -var ComponentBroker = require("../../ComponentBroker"); - module.exports = { getInitialState: function() { return { @@ -35,57 +30,73 @@ module.exports = { }, setStep: function(step) { - this.setState({ step: step, errorText: '', busy: false }); + this.setState({ step: step, busy: false }); }, - onHSChosen: function(ev) { - ev.preventDefault(); + onHSChosen: function() { MatrixClientPeg.replaceUsingUrls( this.getHsUrl(), this.getIsUrl() ); this.setState({ hs_url: this.getHsUrl(), - is_url: this.getIsUrl() + is_url: this.getIsUrl(), }); this.setStep("fetch_stages"); var cli = MatrixClientPeg.get(); - this.setState({busy: true}); - var that = this; + this.setState({ + busy: true, + errorText: "", + }); + var self = this; cli.loginFlows().done(function(result) { - that.setState({ + self.setState({ flows: result.flows, currentStep: 1, totalSteps: result.flows.length+1 }); - that.setStep('stage_'+result.flows[0].type); + self.setStep('stage_'+result.flows[0].type); }, function(error) { - that.setStep("choose_hs"); - that.setState({errorText: 'Unable to contact the given Home Server'}); + self.setStep("choose_hs"); + self.setState({errorText: 'Unable to contact the given Home Server'}); }); }, onUserPassEntered: function(ev) { ev.preventDefault(); - this.setState({busy: true}); - var that = this; + this.setState({ + busy: true, + errorText: "", + }); + var self = this; var formVals = this.getFormVals(); - MatrixClientPeg.get().login('m.login.password', { - 'user': formVals.username, - 'password': formVals.password - }).done(function(data) { + var loginParams = { + password: formVals.password + }; + if (formVals.username.indexOf('@') > 0) { + loginParams.medium = 'email'; + loginParams.address = formVals.username; + } else { + loginParams.user = formVals.username; + } + + MatrixClientPeg.get().login('m.login.password', loginParams).done(function(data) { MatrixClientPeg.replaceUsingAccessToken( - that.state.hs_url, that.state.is_url, + self.state.hs_url, self.state.is_url, data.user_id, data.access_token ); - if (that.props.onLoggedIn) { - that.props.onLoggedIn(); + if (self.props.onLoggedIn) { + self.props.onLoggedIn(); } }, function(error) { - that.setStep("stage_m.login.password"); - that.setState({errorText: 'Login failed.'}); + self.setStep("stage_m.login.password"); + if (error.httpStatus == 400 && loginParams.medium) { + self.setState({errorText: 'This Home Server does not support login using email address.'}); + } else { + self.setState({errorText: 'Login failed.'}); + } }); }, diff --git a/src/controllers/templates/Register.js b/src/controllers/templates/Register.js index 29063fb61e..294329e42e 100644 --- a/src/controllers/templates/Register.js +++ b/src/controllers/templates/Register.js @@ -14,16 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -var React = require('react'); - var MatrixClientPeg = require("../../MatrixClientPeg"); -var Matrix = require("matrix-js-sdk"); var dis = require("../../dispatcher"); -var ComponentBroker = require("../../ComponentBroker"); - module.exports = { FieldErrors: { PasswordMismatch: 'PasswordMismatch', @@ -92,7 +85,7 @@ module.exports = { if (this.refs.recaptchaContainer) { var scriptTag = document.createElement('script'); window.mx_on_recaptcha_loaded = this.onCaptchaLoaded; - scriptTag.setAttribute('src', "https://www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"); + scriptTag.setAttribute('src', global.location.protocol+"//www.google.com/recaptcha/api.js?onload=mx_on_recaptcha_loaded&render=explicit"); this.refs.recaptchaContainer.getDOMNode().appendChild(scriptTag); } }, @@ -196,15 +189,7 @@ module.exports = { hs_url: this.getHsUrl(), is_url: this.getIsUrl() }); - var cli = MatrixClientPeg.get(); this.setState({busy: true}); - var self = this; - - this.savedParams = { - email: formVals.email, - username: formVals.username, - password: formVals.password - }; this.tryRegister(); }, @@ -242,10 +227,14 @@ module.exports = { }); self.setStep('stage_m.login.email.identity'); }, function(error) { - self.setState({ - busy: false, - errorText: 'Unable to contact the given Home Server' - }); + self.setStep('initial'); + var newState = {busy: false}; + if (error.errcode == 'THREEPID_IN_USE') { + self.onBadFields({email: self.FieldErrors.InUse}); + } else { + newState.errorText = 'Unable to contact the given Home Server'; + } + self.setState(newState); }); break; case 'm.login.recaptcha': @@ -326,6 +315,14 @@ module.exports = { }); } else if (error.httpStatus == 401) { newState.errorText = "Authorisation failed!"; + } else if (error.httpStatus >= 400 && error.httpStatus < 500) { + newState.errorText = "Registration failed!"; + } else if (error.httpStatus >= 500 && error.httpStatus < 600) { + newState.errorText = "Server error during registration!"; + } else if (error.name == "M_MISSING_PARAM") { + // The HS hasn't remembered the login params from + // the first try when the login email was sent. + newState.errorText = "This home server does not support resuming registration."; } self.setState(newState); } diff --git a/src/dispatcher.js b/src/dispatcher.js index 3edb9c6947..67d2944cf8 100644 --- a/src/dispatcher.js +++ b/src/dispatcher.js @@ -17,21 +17,20 @@ limitations under the License. 'use strict'; var flux = require("flux"); -var extend = require("./extend"); -var MatrixDispatcher = function() { - flux.Dispatcher.call(this); +class MatrixDispatcher extends flux.Dispatcher { + dispatch(payload) { + if (this.dispatching) { + setTimeout(super.dispatch.bind(this, payload), 0); + } else { + this.dispatching = true; + super.dispatch(payload); + this.dispatching = false; + } + } }; -extend(MatrixDispatcher.prototype, flux.Dispatcher.prototype); -MatrixDispatcher.prototype.dispatch = function(payload) { - if (this.dispatching) { - setTimeout(flux.Dispatcher.prototype.dispatch.bind(this, payload), 0); - } else { - this.dispatching = true; - flux.Dispatcher.prototype.dispatch.call(this, payload); - this.dispatching = false; - } +if (global.mxDispatcher === undefined) { + global.mxDispatcher = new MatrixDispatcher(); } - -module.exports = new MatrixDispatcher(); +module.exports = global.mxDispatcher; diff --git a/src/encryption.js b/src/encryption.js new file mode 100644 index 0000000000..954bc030db --- /dev/null +++ b/src/encryption.js @@ -0,0 +1,38 @@ +/* +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. +*/ + +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/index.js b/src/index.js index febf8d0dc8..5412c051d6 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +var Skinner = require('./Skinner'); +var Modulator = require('./Modulator'); + +module.exports.loadSkin = function(skinObject) { + Skinner.load(skinObject); +}; + +module.exports.loadModule = function(moduleObject) { + Modulator.loadModule(moduleObject); +}; + +module.exports.resetSkin = function() { + Skinner.reset(); +}; + +module.exports.getComponent = function(componentName) { + return Skinner.getComponent(componentName); +}; -module.exports.MatrixChat = require("../skins/base/views/pages/MatrixChat"); diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 273fe123a9..e92a3efc91 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -14,14 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -var extend = require('./extend'); - function matrixLinkify(linkify) { // Text tokens var TT = linkify.scanner.TOKENS; - var TextToken = TT.Base; // Multi tokens var MT = linkify.parser.TOKENS; var MultiToken = MT.Base;