diff --git a/README.md b/README.md index c834e0137..91109f968 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,10 @@ about them: 2. `cd matrix-react-sdk` 3. `git checkout develop` 4. `npm install` -5. `npm start` (to start the dev rebuilder) -6. `cd ../vector-web` -7. Link the react sdk package into the example: +5. `npm run build` +6. `npm start` (to start the dev rebuilder) +7. `cd ../vector-web` +8. Link the react sdk package into the example: `npm link path/to/your/react/sdk` Similarly, you may need to `npm link path/to/your/js/sdk` in your `matrix-react-sdk` @@ -53,6 +54,6 @@ about "Cannot resolve module 'source-map-loader'" due to shortcomings in webpack Deployment ========== -Just run `npm build` and then mount the `vector` directory on your webserver to +Just run `npm run build` and then mount the `vector` directory on your webserver to actually serve up the app, which is entirely static content. diff --git a/package.json b/package.json index eb9c3aff9..035ccbc17 100644 --- a/package.json +++ b/package.json @@ -27,14 +27,20 @@ "classnames": "^2.1.2", "filesize": "^3.1.2", "flux": "~2.0.3", + "gfm.css": "^1.1.1", + "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", + "matrix-js-sdk": "https://github.com/matrix-org/matrix-js-sdk.git#develop", + "matrix-react-sdk": "https://github.com/matrix-org/matrix-react-sdk.git#develop", "modernizr": "^3.1.0", - "matrix-js-sdk": "^0.3.0", - "matrix-react-sdk": "^0.0.2", "q": "^1.4.1", - "react": "^0.13.3", - "react-loader": "^1.4.0", - "sanitize-html": "^1.11.1" + "react": "^0.14.2", + "react-dnd": "^2.0.2", + "react-dnd-html5-backend": "^2.0.0", + "react-dom": "^0.14.2", + "react-gemini-scrollbar": "^2.0.1", + "sanitize-html": "^1.0.0", + "velocity-animate": "^1.2.3" }, "devDependencies": { "babel": "^5.8.23", @@ -46,6 +52,7 @@ "parallelshell": "^1.2.0", "rimraf": "^2.4.3", "source-map-loader": "^0.1.5", - "uglifycss": "0.0.15" + "uglifycss": "0.0.15", + "webpack": "^1.12.6" } } diff --git a/src/ContextualMenu.js b/src/ContextualMenu.js index 7865e45a7..a7b1849e1 100644 --- a/src/ContextualMenu.js +++ b/src/ContextualMenu.js @@ -18,6 +18,7 @@ limitations under the License. 'use strict'; var React = require('react'); +var ReactDOM = require('react-dom'); // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -42,7 +43,7 @@ module.exports = { var self = this; var closeMenu = function() { - React.unmountComponentAtNode(self.getOrCreateContainer()); + ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); if (props && props.onFinished) props.onFinished.apply(null, arguments); }; @@ -74,7 +75,7 @@ module.exports = { ); - React.render(menu, this.getOrCreateContainer()); + ReactDOM.render(menu, this.getOrCreateContainer()); return {close: closeMenu}; }, diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js new file mode 100644 index 000000000..824f59ab2 --- /dev/null +++ b/src/HtmlUtils.js @@ -0,0 +1,108 @@ +/* +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 sanitizeHtml = require('sanitize-html'); +var highlight = require('highlight.js'); + +var sanitizeHtmlParams = { + allowedTags: [ + 'font', // custom to matrix. deliberately no h1/h2 to stop people shouting. + 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', + 'nl', 'li', 'b', 'i', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', + 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre' + ], + allowedAttributes: { + // custom ones first: + font: [ 'color' ], // custom to matrix + a: [ 'href', 'name', 'target' ], // remote target: custom to matrix + // We don't currently allow img itself by default, but this + // would make sense if we did + img: [ 'src' ], + }, + // Lots of these won't come up by default because we don't allow them + selfClosing: [ 'img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta' ], + // URL schemes we permit + allowedSchemes: [ 'http', 'https', 'ftp', 'mailto' ], + allowedSchemesByTag: {}, + + transformTags: { // custom to matrix + // add blank targets to all hyperlinks + 'a': sanitizeHtml.simpleTransform('a', { target: '_blank'} ) + }, +}; + +module.exports = { + bodyToHtml: function(content, searchTerm) { + var originalBody = content.body; + var body; + + if (searchTerm) { + var lastOffset = 0; + var bodyList = []; + var k = 0; + var offset; + + // XXX: rather than searching for the search term in the body, + // we should be looking at the match delimiters returned by the FTS engine + if (content.format === "org.matrix.custom.html") { + + var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); + var safeSearchTerm = sanitizeHtml(searchTerm, sanitizeHtmlParams); + while ((offset = safeBody.indexOf(safeSearchTerm, lastOffset)) >= 0) { + // FIXME: we need to apply the search highlighting to only the text elements of HTML, which means + // hooking into the sanitizer parser rather than treating it as a string. Otherwise + // the act of highlighting a or whatever will break the HTML badly. + bodyList.push(); + bodyList.push(); + lastOffset = offset + safeSearchTerm.length; + } + bodyList.push(); + } + else { + while ((offset = originalBody.indexOf(searchTerm, lastOffset)) >= 0) { + bodyList.push({ originalBody.substring(lastOffset, offset) }); + bodyList.push({ searchTerm }); + lastOffset = offset + searchTerm.length; + } + bodyList.push({ originalBody.substring(lastOffset) }); + } + body = bodyList; + } + else { + if (content.format === "org.matrix.custom.html") { + var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); + body = ; + } + else { + body = originalBody; + } + } + + return body; + }, + + highlightDom: function(element) { + var blocks = element.getElementsByTagName("code"); + for (var i = 0; i < blocks.length; i++) { + highlight.highlightBlock(blocks[i]); + } + }, + +} + diff --git a/src/Resend.js b/src/Resend.js new file mode 100644 index 000000000..52b7c9365 --- /dev/null +++ b/src/Resend.js @@ -0,0 +1,24 @@ +var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg'); +var dis = require('matrix-react-sdk/lib/dispatcher'); + +module.exports = { + resend: function(event) { + MatrixClientPeg.get().resendEvent( + event, MatrixClientPeg.get().getRoom(event.getRoomId()) + ).done(function() { + dis.dispatch({ + action: 'message_sent', + event: event + }); + }, function() { + dis.dispatch({ + action: 'message_send_failed', + event: event + }); + }); + dis.dispatch({ + action: 'message_resend_started', + event: event + }); + }, +}; \ No newline at end of file diff --git a/src/Velociraptor.js b/src/Velociraptor.js new file mode 100644 index 000000000..d973a17f7 --- /dev/null +++ b/src/Velociraptor.js @@ -0,0 +1,113 @@ +var React = require('react'); +var ReactDom = require('react-dom'); +var Velocity = require('velocity-animate'); + +/** + * The Velociraptor contains components and animates transitions with velocity. + * It will only pick up direct changes to properties ('left', currently), and so + * will not work for animating positional changes where the position is implicit + * from DOM order. This makes it a lot simpler and lighter: if you need fully + * automatic positional animation, look at react-shuffle or similar libraries. + */ +module.exports = React.createClass({ + displayName: 'Velociraptor', + + propTypes: { + children: React.PropTypes.array, + transition: React.PropTypes.object, + container: React.PropTypes.string + }, + + componentWillMount: function() { + this.children = {}; + this.nodes = {}; + var self = this; + React.Children.map(this.props.children, function(c) { + self.children[c.key] = c; + }); + }, + + componentWillReceiveProps: function(nextProps) { + var self = this; + var oldChildren = this.children; + this.children = {}; + React.Children.map(nextProps.children, function(c) { + if (oldChildren[c.key]) { + var old = oldChildren[c.key]; + var oldNode = ReactDom.findDOMNode(self.nodes[old.key]); + + if (oldNode.style.left != c.props.style.left) { + Velocity(oldNode, { left: c.props.style.left }, self.props.transition).then(function() { + // special case visibility because it's nonsensical to animate an invisible element + // so we always hidden->visible pre-transition and visible->hidden after + if (oldNode.style.visibility == 'visible' && c.props.style.visibility == 'hidden') { + oldNode.style.visibility = c.props.style.visibility; + } + }); + if (oldNode.style.visibility == 'hidden' && c.props.style.visibility == 'visible') { + oldNode.style.visibility = c.props.style.visibility; + } + //console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); + } + self.children[c.key] = old; + } else { + // new element. If it has a startStyle, use that as the style and go through + // the enter animations + var newProps = { + ref: self.collectNode.bind(self, c.key) + }; + if (c.props.startStyle && Object.keys(c.props.startStyle).length) { + var startStyle = c.props.startStyle; + if (Array.isArray(startStyle)) { + startStyle = startStyle[0]; + } + newProps._restingStyle = c.props.style; + newProps.style = startStyle; + //console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); + // apply the enter animations once it's mounted + } + self.children[c.key] = React.cloneElement(c, newProps); + } + }); + }, + + collectNode: function(k, node) { + if ( + this.nodes[k] === undefined && + node.props.startStyle && + Object.keys(node.props.startStyle).length + ) { + var domNode = ReactDom.findDOMNode(node); + var startStyles = node.props.startStyle; + var transitionOpts = node.props.enterTransitionOpts; + if (!Array.isArray(startStyles)) { + startStyles = [ startStyles ]; + transitionOpts = [ transitionOpts ]; + } + // start from startStyle 1: 0 is the one we gave it + // to start with, so now we animate 1 etc. + for (var i = 1; i < startStyles.length; ++i) { + Velocity(domNode, startStyles[i], transitionOpts[i-1]); + //console.log("start: "+JSON.stringify(startStyles[i])); + } + // and then we animate to the resting state + Velocity(domNode, node.props._restingStyle, transitionOpts[i-1]); + //console.log("enter: "+JSON.stringify(node.props._restingStyle)); + } + this.nodes[k] = node; + }, + + render: function() { + var self = this; + var childList = Object.keys(this.children).map(function(k) { + return React.cloneElement(self.children[k], { + ref: self.collectNode.bind(self, self.children[k].key) + }); + }); + return ( + + {childList} + + ); + }, +}); diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js new file mode 100644 index 000000000..c85aa254f --- /dev/null +++ b/src/VelocityBounce.js @@ -0,0 +1,15 @@ +var Velocity = require('velocity-animate'); + +// courtesy of https://github.com/julianshapiro/velocity/issues/283 +// We only use easeOutBounce (easeInBounce is just sort of nonsensical) +function bounce( p ) { + var pow2, + bounce = 4; + + while ( p < ( ( pow2 = Math.pow( 2, --bounce ) ) - 1 ) / 11 ) {} + return 1 / Math.pow( 4, 3 - bounce ) - 7.5625 * Math.pow( ( pow2 * 3 - 2 ) / 22 - p, 2 ); +} + +Velocity.Easings.easeOutBounce = function(p) { + return 1 - bounce(1 - p); +} diff --git a/src/components/login/Login.js b/src/components/login/Login.js new file mode 100644 index 000000000..80d0314fc --- /dev/null +++ b/src/components/login/Login.js @@ -0,0 +1,199 @@ +/* +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 ReactDOM = require('react-dom'); +var sdk = require('matrix-react-sdk'); +var Signup = require("matrix-react-sdk/lib/Signup"); +var PasswordLogin = require("matrix-react-sdk/lib/components/login/PasswordLogin"); +var CasLogin = require("matrix-react-sdk/lib/components/login/CasLogin"); +var ServerConfig = require("./ServerConfig"); + +/** + * A wire component which glues together login UI components and Signup logic + */ +module.exports = React.createClass({displayName: 'Login', + propTypes: { + onLoggedIn: React.PropTypes.func.isRequired, + homeserverUrl: React.PropTypes.string, + identityServerUrl: React.PropTypes.string, + // login shouldn't know or care how registration is done. + onRegisterClick: React.PropTypes.func.isRequired + }, + + getDefaultProps: function() { + return { + homeserverUrl: 'https://matrix.org/', + identityServerUrl: 'https://vector.im' + }; + }, + + getInitialState: function() { + return { + busy: false, + errorText: null, + enteredHomeserverUrl: this.props.homeserverUrl, + enteredIdentityServerUrl: this.props.identityServerUrl + }; + }, + + componentWillMount: function() { + this._initLoginLogic(); + }, + + onPasswordLogin: function(username, password) { + var self = this; + self.setState({ + busy: true + }); + + this._loginLogic.loginViaPassword(username, password).then(function(data) { + self.props.onLoggedIn(data); + }, function(error) { + self._setErrorTextFromError(error); + }).finally(function() { + self.setState({ + busy: false + }); + }); + }, + + onHsUrlChanged: function(newHsUrl) { + this._initLoginLogic(newHsUrl); + }, + + onIsUrlChanged: function(newIsUrl) { + this._initLoginLogic(null, newIsUrl); + }, + + _initLoginLogic: function(hsUrl, isUrl) { + var self = this; + hsUrl = hsUrl || this.state.enteredHomeserverUrl; + isUrl = isUrl || this.state.enteredIdentityServerUrl; + + var loginLogic = new Signup.Login(hsUrl, isUrl); + this._loginLogic = loginLogic; + + loginLogic.getFlows().then(function(flows) { + // old behaviour was to always use the first flow without presenting + // options. This works in most cases (we don't have a UI for multiple + // logins so let's skip that for now). + loginLogic.chooseFlow(0); + }, function(err) { + self._setErrorTextFromError(err); + }).finally(function() { + self.setState({ + busy: false + }); + }); + + this.setState({ + enteredHomeserverUrl: hsUrl, + enteredIdentityServerUrl: isUrl, + busy: true, + errorText: null // reset err messages + }); + }, + + _getCurrentFlowStep: function() { + return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null + }, + + _setErrorTextFromError: function(err) { + if (err.friendlyText) { + this.setState({ + errorText: err.friendlyText + }); + return; + } + + var errCode = err.errcode; + if (!errCode && err.httpStatus) { + errCode = "HTTP " + err.httpStatus; + } + this.setState({ + errorText: ( + "Error: Problem communicating with the given homeserver " + + (errCode ? "(" + errCode + ")" : "") + ) + }); + }, + + componentForStep: function(step) { + switch (step) { + case 'm.login.password': + return ( + + ); + case 'm.login.cas': + return ( + + ); + default: + if (!step) { + return; + } + return ( +
+ Sorry, this homeserver is using a login which is not + recognised by Vector ({step}) +
+ ); + } + }, + + render: function() { + var Loader = sdk.getComponent("atoms.Spinner"); + var loader = this.state.busy ?
: null; + + return ( +
+
+
+ vector +
+
+

Sign in

+ { this.componentForStep(this._getCurrentFlowStep()) } + +
+ { loader } + { this.state.errorText } +
+ + Create a new account + +
+
+ blog  ·   + twitter  ·   + github  ·   + powered by Matrix +
+
+
+
+ ); + } +}); diff --git a/src/components/login/PostRegistration.js b/src/components/login/PostRegistration.js new file mode 100644 index 000000000..20a58f114 --- /dev/null +++ b/src/components/login/PostRegistration.js @@ -0,0 +1,81 @@ +/* +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 sdk = require('matrix-react-sdk'); +var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg'); + +module.exports = React.createClass({ + displayName: 'PostRegistration', + + propTypes: { + onComplete: React.PropTypes.func.isRequired + }, + + getInitialState: function() { + return { + avatarUrl: null, + errorString: null, + busy: false + }; + }, + + componentWillMount: function() { + // There is some assymetry between ChangeDisplayName and ChangeAvatar, + // as ChangeDisplayName will auto-get the name but ChangeAvatar expects + // the URL to be passed to you (because it's also used for room avatars). + var cli = MatrixClientPeg.get(); + this.setState({busy: true}); + var self = this; + cli.getProfileInfo(cli.credentials.userId).done(function(result) { + self.setState({ + avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url), + busy: false + }); + }, function(error) { + self.setState({ + errorString: "Failed to fetch avatar URL", + busy: false + }); + }); + }, + + render: function() { + var ChangeDisplayName = sdk.getComponent('molecules.ChangeDisplayName'); + var ChangeAvatar = sdk.getComponent('molecules.ChangeAvatar'); + return ( +
+
+
+ vector +
+
+ Set a display name: + + Upload an avatar: + + + {this.state.errorString} +
+
+
+ ); + } +}); diff --git a/src/components/login/Registration.js b/src/components/login/Registration.js new file mode 100644 index 000000000..b06f9ffef --- /dev/null +++ b/src/components/login/Registration.js @@ -0,0 +1,247 @@ +/* +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 sdk = require('matrix-react-sdk'); +var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg'); +var dis = require('matrix-react-sdk/lib/dispatcher'); +var ServerConfig = require("./ServerConfig"); +var RegistrationForm = require("./RegistrationForm"); +var CaptchaForm = require("matrix-react-sdk/lib/components/login/CaptchaForm"); +var Signup = require("matrix-react-sdk/lib/Signup"); +var MIN_PASSWORD_LENGTH = 6; + +module.exports = React.createClass({ + displayName: 'Registration', + + propTypes: { + onLoggedIn: React.PropTypes.func.isRequired, + clientSecret: React.PropTypes.string, + sessionId: React.PropTypes.string, + registrationUrl: React.PropTypes.string, + idSid: React.PropTypes.string, + hsUrl: React.PropTypes.string, + isUrl: React.PropTypes.string, + // registration shouldn't know or care how login is done. + onLoginClick: React.PropTypes.func.isRequired + }, + + getInitialState: function() { + return { + busy: false, + errorText: null, + enteredHomeserverUrl: this.props.hsUrl, + enteredIdentityServerUrl: this.props.isUrl + }; + }, + + componentWillMount: function() { + this.dispatcherRef = dis.register(this.onAction); + // attach this to the instance rather than this.state since it isn't UI + this.registerLogic = new Signup.Register( + this.props.hsUrl, this.props.isUrl + ); + this.registerLogic.setClientSecret(this.props.clientSecret); + this.registerLogic.setSessionId(this.props.sessionId); + this.registerLogic.setRegistrationUrl(this.props.registrationUrl); + this.registerLogic.setIdSid(this.props.idSid); + this.registerLogic.recheckState(); + }, + + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + }, + + componentDidMount: function() { + // may have already done an HTTP hit (e.g. redirect from an email) so + // check for any pending response + var promise = this.registerLogic.getPromise(); + if (promise) { + this.onProcessingRegistration(promise); + } + }, + + onHsUrlChanged: function(newHsUrl) { + this.registerLogic.setHomeserverUrl(newHsUrl); + }, + + onIsUrlChanged: function(newIsUrl) { + this.registerLogic.setIdentityServerUrl(newIsUrl); + }, + + onAction: function(payload) { + if (payload.action !== "registration_step_update") { + return; + } + this.forceUpdate(); // registration state has changed. + }, + + onFormSubmit: function(formVals) { + var self = this; + this.setState({ + errorText: "", + busy: true + }); + this.onProcessingRegistration(this.registerLogic.register(formVals)); + }, + + // Promise is resolved when the registration process is FULLY COMPLETE + onProcessingRegistration: function(promise) { + var self = this; + promise.done(function(response) { + if (!response || !response.access_token) { + console.warn( + "FIXME: Register fulfilled without a final response, " + + "did you break the promise chain?" + ); + // no matter, we'll grab it direct + response = self.registerLogic.getCredentials(); + } + if (!response || !response.user_id || !response.access_token) { + console.error("Final response is missing keys."); + self.setState({ + errorText: "There was a problem processing the response." + }); + return; + } + self.props.onLoggedIn({ + userId: response.user_id, + homeserverUrl: self.registerLogic.getHomeserverUrl(), + identityServerUrl: self.registerLogic.getIdentityServerUrl(), + accessToken: response.access_token + }); + self.setState({ + busy: false + }); + }, function(err) { + if (err.message) { + self.setState({ + errorText: err.message + }); + } + self.setState({ + busy: false + }); + console.log(err); + }); + }, + + onFormValidationFailed: function(errCode) { + var errMsg; + switch (errCode) { + case "RegistrationForm.ERR_PASSWORD_MISSING": + errMsg = "Missing password."; + break; + case "RegistrationForm.ERR_PASSWORD_MISMATCH": + errMsg = "Passwords don't match."; + break; + case "RegistrationForm.ERR_PASSWORD_LENGTH": + errMsg = `Password too short (min ${MIN_PASSWORD_LENGTH}).`; + break; + default: + console.error("Unknown error code: %s", errCode); + errMsg = "An unknown error occurred."; + break; + } + this.setState({ + errorText: errMsg + }); + }, + + onCaptchaLoaded: function(divIdName) { + this.registerLogic.tellStage("m.login.recaptcha", { + divId: divIdName + }); + this.setState({ + busy: false // requires user input + }); + }, + + _getRegisterContentJsx: function() { + var currStep = this.registerLogic.getStep(); + var registerStep; + switch (currStep) { + case "Register.COMPLETE": + break; // NOP + case "Register.START": + case "Register.STEP_m.login.dummy": + registerStep = ( + + ); + break; + case "Register.STEP_m.login.email.identity": + registerStep = ( +
+ Please check your email to continue registration. +
+ ); + break; + case "Register.STEP_m.login.recaptcha": + registerStep = ( + + ); + break; + default: + console.error("Unknown register state: %s", currStep); + break; + } + var busySpinner; + if (this.state.busy) { + var Spinner = sdk.getComponent("atoms.Spinner"); + busySpinner = ( + + ); + } + return ( +
+

Create an account

+ {registerStep} +
{this.state.errorText}
+ {busySpinner} + + + I already have an account + +
+ ); + }, + + render: function() { + return ( +
+
+
+ vector +
+ {this._getRegisterContentJsx()} +
+
+ ); + } +}); diff --git a/src/components/login/RegistrationForm.js b/src/components/login/RegistrationForm.js new file mode 100644 index 000000000..3f9fb6ae1 --- /dev/null +++ b/src/components/login/RegistrationForm.js @@ -0,0 +1,126 @@ +/* +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 sdk = require('matrix-react-sdk') + +/** + * A pure UI component which displays a registration form. + */ +module.exports = React.createClass({ + displayName: 'RegistrationForm', + + propTypes: { + defaultEmail: React.PropTypes.string, + defaultUsername: React.PropTypes.string, + showEmail: React.PropTypes.bool, + minPasswordLength: React.PropTypes.number, + onError: React.PropTypes.func, + onRegisterClick: React.PropTypes.func // onRegisterClick(Object) => ?Promise + }, + + getDefaultProps: function() { + return { + showEmail: false, + minPasswordLength: 6, + onError: function(e) { + console.error(e); + } + }; + }, + + getInitialState: function() { + return { + email: this.props.defaultEmail, + username: this.props.defaultUsername, + password: null, + passwordConfirm: null + }; + }, + + onSubmit: function(ev) { + ev.preventDefault(); + + var pwd1 = this.refs.password.value.trim(); + var pwd2 = this.refs.passwordConfirm.value.trim() + + var errCode; + if (!pwd1 || !pwd2) { + errCode = "RegistrationForm.ERR_PASSWORD_MISSING"; + } + else if (pwd1 !== pwd2) { + errCode = "RegistrationForm.ERR_PASSWORD_MISMATCH"; + } + else if (pwd1.length < this.props.minPasswordLength) { + errCode = "RegistrationForm.ERR_PASSWORD_LENGTH"; + } + if (errCode) { + this.props.onError(errCode); + return; + } + + var promise = this.props.onRegisterClick({ + username: this.refs.username.value.trim(), + password: pwd1, + email: this.refs.email.value.trim() + }); + + if (promise) { + ev.target.disabled = true; + promise.finally(function() { + ev.target.disabled = false; + }); + } + }, + + render: function() { + var emailSection, registerButton; + if (this.props.showEmail) { + emailSection = ( + + ); + } + if (this.props.onRegisterClick) { + registerButton = ( + + ); + } + + return ( +
+
+ {emailSection} +
+ +
+ +
+ +
+ {registerButton} +
+
+ ); + } +}); diff --git a/src/components/login/ServerConfig.js b/src/components/login/ServerConfig.js new file mode 100644 index 000000000..8f1eab384 --- /dev/null +++ b/src/components/login/ServerConfig.js @@ -0,0 +1,161 @@ +/* +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 Modal = require('matrix-react-sdk/lib/Modal'); +var sdk = require('matrix-react-sdk') + +/** + * A pure UI component which displays the HS and IS to use. + */ +module.exports = React.createClass({ + displayName: 'ServerConfig', + + propTypes: { + onHsUrlChanged: React.PropTypes.func, + onIsUrlChanged: React.PropTypes.func, + defaultHsUrl: React.PropTypes.string, + defaultIsUrl: React.PropTypes.string, + withToggleButton: React.PropTypes.bool, + delayTimeMs: React.PropTypes.number // time to wait before invoking onChanged + }, + + getDefaultProps: function() { + return { + onHsUrlChanged: function() {}, + onIsUrlChanged: function() {}, + withToggleButton: false, + delayTimeMs: 0 + }; + }, + + getInitialState: function() { + return { + hs_url: this.props.defaultHsUrl, + is_url: this.props.defaultIsUrl, + original_hs_url: this.props.defaultHsUrl, + original_is_url: this.props.defaultIsUrl, + // no toggle button = show, toggle button = hide + configVisible: !this.props.withToggleButton + } + }, + + onHomeserverChanged: function(ev) { + this.setState({hs_url: ev.target.value}, function() { + this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, function() { + this.props.onHsUrlChanged(this.state.hs_url); + }); + }); + }, + + onIdentityServerChanged: function(ev) { + this.setState({is_url: ev.target.value}, function() { + this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, function() { + this.props.onIsUrlChanged(this.state.is_url); + }); + }); + }, + + _waitThenInvoke: function(existingTimeoutId, fn) { + if (existingTimeoutId) { + clearTimeout(existingTimeoutId); + } + return setTimeout(fn.bind(this), this.props.delayTimeMs); + }, + + getHsUrl: function() { + return this.state.hs_url; + }, + + getIsUrl: function() { + return this.state.is_url; + }, + + onServerConfigVisibleChange: function(ev) { + this.setState({ + configVisible: ev.target.checked + }); + }, + + showHelpPopup: function() { + var ErrorDialog = sdk.getComponent('organisms.ErrorDialog'); + Modal.createDialog(ErrorDialog, { + title: 'Custom Server Options', + description: + You can use the custom server options to log into other Matrix + servers by specifying a different Home server URL. +
+ This allows you to use Vector with an existing Matrix account on + a different Home server. +
+
+ You can also set a custom Identity server but this will affect + people's ability to find you if you use a server in a group other + than the main Matrix.org group. +
, + button: "Dismiss", + focus: true + }); + }, + + render: function() { + var serverConfigStyle = {}; + serverConfigStyle.display = this.state.configVisible ? 'block' : 'none'; + + var toggleButton; + if (this.props.withToggleButton) { + toggleButton = ( +
+ + +
+ ); + } + + return ( +
+ {toggleButton} +
+
+ + + + + + What does this mean? + +
+
+
+ ); + } +}); diff --git a/src/controllers/organisms/RoomList.js b/src/controllers/organisms/RoomList.js index 964a26481..2a01527e7 100644 --- a/src/controllers/organisms/RoomList.js +++ b/src/controllers/organisms/RoomList.js @@ -17,22 +17,31 @@ limitations under the License. 'use strict'; var React = require("react"); +var ReactDOM = require("react-dom"); + var MatrixClientPeg = require("matrix-react-sdk/lib/MatrixClientPeg"); var RoomListSorter = require("matrix-react-sdk/lib/RoomListSorter"); var dis = require("matrix-react-sdk/lib/dispatcher"); var sdk = require('matrix-react-sdk'); var VectorConferenceHandler = require("../../modules/VectorConferenceHandler"); -var CallHandler = require("matrix-react-sdk/lib/CallHandler"); var HIDE_CONFERENCE_CHANS = true; module.exports = { + getInitialState: function() { + return { + activityMap: null, + lists: {}, + } + }, + componentWillMount: function() { var cli = MatrixClientPeg.get(); cli.on("Room", this.onRoom); cli.on("Room.timeline", this.onRoomTimeline); cli.on("Room.name", this.onRoomName); + cli.on("Room.tags", this.onRoomTags); cli.on("RoomState.events", this.onRoomStateEvents); cli.on("RoomMember.name", this.onRoomMemberName); @@ -47,11 +56,6 @@ module.exports = { onAction: function(payload) { switch (payload.action) { - // listen for call state changes to prod the render method, which - // may hide the global CallView if the call it is tracking is dead - case 'call_state': - this._recheckCallElement(this.props.selectedRoom); - break; case 'view_tooltip': this.tooltip = payload.tooltip; this._repositionTooltip(); @@ -72,7 +76,6 @@ module.exports = { componentWillReceiveProps: function(newProps) { this.state.activityMap[newProps.selectedRoom] = undefined; - this._recheckCallElement(newProps.selectedRoom); this.setState({ activityMap: this.state.activityMap }); @@ -85,30 +88,43 @@ module.exports = { onRoomTimeline: function(ev, room, toStartOfTimeline) { if (toStartOfTimeline) return; - var newState = this.getRoomLists(); + var hl = 0; if ( room.roomId != this.props.selectedRoom && ev.getSender() != MatrixClientPeg.get().credentials.userId) { - var hl = 1; + // don't mark rooms as unread for just member changes + if (ev.getType() != "m.room.member") { + hl = 1; + } var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.tweaks && actions.tweaks.highlight) { hl = 2; } + } + + if (hl > 0) { + var newState = this.getRoomLists(); + // 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; + + this.setState(newState); } - this.setState(newState); }, onRoomName: function(room) { this.refreshRoomList(); }, + onRoomTags: function(event, room) { + this.refreshRoomList(); + }, + onRoomStateEvents: function(ev, state) { setTimeout(this.refreshRoomList, 0); }, @@ -117,26 +133,36 @@ module.exports = { setTimeout(this.refreshRoomList, 0); }, - refreshRoomList: function() { + // TODO: rather than bluntly regenerating and re-sorting everything + // every time we see any kind of room change from the JS SDK + // we could do incremental updates on our copy of the state + // based on the room which has actually changed. This would stop + // us re-rendering all the sublists every time anything changes anywhere + // in the state of the client. this.setState(this.getRoomLists()); }, getRoomLists: function() { - var s = {}; - var inviteList = []; - s.roomList = RoomListSorter.mostRecentActivityFirst( - MatrixClientPeg.get().getRooms().filter(function(room) { - var me = room.getMember(MatrixClientPeg.get().credentials.userId); + var s = { lists: {} }; - if (me && me.membership == "invite") { - inviteList.push(room); - return false; - } + s.lists["m.invite"] = []; + s.lists["m.favourite"] = []; + s.lists["m.recent"] = []; + s.lists["m.lowpriority"] = []; + s.lists["m.archived"] = []; + MatrixClientPeg.get().getRooms().forEach(function(room) { + var me = room.getMember(MatrixClientPeg.get().credentials.userId); + + if (me && me.membership == "invite") { + s.lists["m.invite"].push(room); + } + else { var shouldShowRoom = ( me && (me.membership == "join") ); + // hiding conf rooms only ever toggles shouldShowRoom to false if (shouldShowRoom && HIDE_CONFERENCE_CHANS) { // we want to hide the 1:1 conf<->user room and not the group chat @@ -151,48 +177,34 @@ module.exports = { } } } - return shouldShowRoom; - }) - ); - s.inviteList = RoomListSorter.mostRecentActivityFirst(inviteList); - return s; - }, - _recheckCallElement: function(selectedRoomId) { - // if we aren't viewing a room with an ongoing call, but there is an - // active call, show the call element - we need to do this to make - // audio/video not crap out - var activeCall = CallHandler.getAnyActiveCall(); - var callForRoom = CallHandler.getCallForRoom(selectedRoomId); - var showCall = (activeCall && !callForRoom); - this.setState({ - show_call_element: showCall + if (shouldShowRoom) { + var tagNames = Object.keys(room.tags); + if (tagNames.length) { + for (var i = 0; i < tagNames.length; i++) { + var tagName = tagNames[i]; + s.lists[tagName] = s.lists[tagName] || []; + s.lists[tagNames[i]].push(room); + } + } + else { + s.lists["m.recent"].push(room); + } + } + } }); + + //console.log("calculated new roomLists; m.recent = " + s.lists["m.recent"]); + + // we actually apply the sorting to this when receiving the prop in RoomSubLists. + + return s; }, _repositionTooltip: function(e) { if (this.tooltip && this.tooltip.parentElement) { - var scroll = this.getDOMNode(); - this.tooltip.style.top = (scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - scroll.scrollTop) + "px"; + var scroll = ReactDOM.findDOMNode(this); + this.tooltip.style.top = (scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - scroll.children[2].scrollTop) + "px"; } }, - - makeRoomTiles: function(list, isInvite) { - var self = this; - var RoomTile = sdk.getComponent("molecules.RoomTile"); - return list.map(function(room) { - var selected = room.roomId == self.props.selectedRoom; - return ( - - ); - }); - } }; diff --git a/src/controllers/organisms/RoomView.js b/src/controllers/organisms/RoomView.js index f5a8d28f2..7baab0e10 100644 --- a/src/controllers/organisms/RoomView.js +++ b/src/controllers/organisms/RoomView.js @@ -17,6 +17,7 @@ limitations under the License. var Matrix = require("matrix-js-sdk"); var MatrixClientPeg = require("matrix-react-sdk/lib/MatrixClientPeg"); var React = require("react"); +var ReactDOM = require("react-dom"); var q = require("q"); var ContentMessages = require("matrix-react-sdk/lib//ContentMessages"); var WhoIsTyping = require("matrix-react-sdk/lib/WhoIsTyping"); @@ -24,6 +25,7 @@ var Modal = require("matrix-react-sdk/lib/Modal"); var sdk = require('matrix-react-sdk/lib/index'); var CallHandler = require('matrix-react-sdk/lib/CallHandler'); var VectorConferenceHandler = require('../../modules/VectorConferenceHandler'); +var Resend = require("../../Resend"); var dis = require("matrix-react-sdk/lib/dispatcher"); @@ -32,8 +34,9 @@ var INITIAL_SIZE = 20; module.exports = { getInitialState: function() { + var room = this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null; return { - room: this.props.roomId ? MatrixClientPeg.get().getRoom(this.props.roomId) : null, + room: room, messageCap: INITIAL_SIZE, editingRoomSettings: false, uploadingRoomSettings: false, @@ -41,6 +44,8 @@ module.exports = { draggingFile: false, searching: false, searchResults: null, + syncState: MatrixClientPeg.get().getSyncState(), + hasUnsentMessages: this._hasUnsentMessages(room) } }, @@ -48,25 +53,29 @@ module.exports = { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.name", this.onRoomName); + MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().on("sync", this.onSyncStateChange); this.atBottom = true; }, componentWillUnmount: function() { - 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); + if (this.refs.messagePanel) { + var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); + messagePanel.removeEventListener('drop', this.onDrop); + messagePanel.removeEventListener('dragover', this.onDragOver); + messagePanel.removeEventListener('dragleave', this.onDragLeaveOrEnd); + messagePanel.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("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); + MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange); } }, @@ -74,6 +83,9 @@ module.exports = { switch (payload.action) { case 'message_send_failed': case 'message_sent': + this.setState({ + hasUnsentMessages: this._hasUnsentMessages(this.state.room) + }); case 'message_resend_started': this.setState({ room: MatrixClientPeg.get().getRoom(this.props.roomId) @@ -88,10 +100,9 @@ module.exports = { // Call state has changed so we may be loading video elements // which will obscure the message log. // scroll to bottom - var messageWrapper = this.refs.messageWrapper; - if (messageWrapper) { - messageWrapper = messageWrapper.getDOMNode(); - messageWrapper.scrollTop = messageWrapper.scrollHeight; + var scrollNode = this._getScrollNode(); + if (scrollNode) { + scrollNode.scrollTop = scrollNode.scrollHeight; } } @@ -99,9 +110,29 @@ module.exports = { // the conf this._updateConfCallNotification(); break; + case 'user_activity': + this.sendReadReceipt(); + break; } }, + _getScrollNode: function() { + var panel = ReactDOM.findDOMNode(this.refs.messagePanel); + if (!panel) return null; + + if (panel.classList.contains('gm-prevented')) { + return panel; + } else { + return panel.children[2]; // XXX: Fragile! + } + }, + + onSyncStateChange: function(state) { + this.setState({ + syncState: state + }); + }, + // MatrixRoom still showing the messages from the old room? // Set the key to the room_id. Sadly you can no longer get at // the key from inside the component, or we'd check this in code. @@ -111,7 +142,7 @@ module.exports = { onRoomTimeline: function(ev, room, toStartOfTimeline) { if (!this.isMounted()) return; - // ignore anything that comes in whilst pagingating: we get one + // ignore anything that comes in whilst paginating: we get one // event for each new matrix event so this would cause a huge // number of UI updates. Just update the UI when the paginate // call returns. @@ -122,11 +153,11 @@ module.exports = { if (this.state.joining) return; if (room.roomId != this.props.roomId) return; - if (this.refs.messageWrapper) { - var messageWrapper = this.refs.messageWrapper.getDOMNode(); + var scrollNode = this._getScrollNode(); + if (scrollNode) { this.atBottom = ( - messageWrapper.scrollHeight - messageWrapper.scrollTop <= - (messageWrapper.clientHeight + 150) + scrollNode.scrollHeight - scrollNode.scrollTop <= + (scrollNode.clientHeight + 150) // 150? ); } @@ -161,6 +192,12 @@ module.exports = { } }, + onRoomReceipt: function(receiptEvent, room) { + if (room.roomId == this.props.roomId) { + this.forceUpdate(); + } + }, + onRoomMemberTyping: function(ev, member) { this.forceUpdate(); }, @@ -173,6 +210,19 @@ module.exports = { this._updateConfCallNotification(); }, + _hasUnsentMessages: function(room) { + return this._getUnsentMessages(room).length > 0; + }, + + _getUnsentMessages: function(room) { + if (!room) { return []; } + // TODO: It would be nice if the JS SDK provided nicer constant-time + // constructs rather than O(N) (N=num msgs) on this. + return room.timeline.filter(function(ev) { + return ev.status === Matrix.EventStatus.NOT_SENT; + }); + }, + _updateConfCallNotification: function() { var room = MatrixClientPeg.get().getRoom(this.props.roomId); if (!room) return; @@ -196,15 +246,19 @@ module.exports = { }, componentDidMount: function() { - if (this.refs.messageWrapper) { - var messageWrapper = this.refs.messageWrapper.getDOMNode(); + if (this.refs.messagePanel) { + var messagePanel = ReactDOM.findDOMNode(this.refs.messagePanel); - messageWrapper.addEventListener('drop', this.onDrop); - messageWrapper.addEventListener('dragover', this.onDragOver); - messageWrapper.addEventListener('dragleave', this.onDragLeaveOrEnd); - messageWrapper.addEventListener('dragend', this.onDragLeaveOrEnd); + messagePanel.addEventListener('drop', this.onDrop); + messagePanel.addEventListener('dragover', this.onDragOver); + messagePanel.addEventListener('dragleave', this.onDragLeaveOrEnd); + messagePanel.addEventListener('dragend', this.onDragLeaveOrEnd); - messageWrapper.scrollTop = messageWrapper.scrollHeight; + var messageWrapperScroll = this._getScrollNode(); + + messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight; + + this.sendReadReceipt(); this.fillSpace(); } @@ -213,19 +267,19 @@ module.exports = { }, componentDidUpdate: function() { - if (!this.refs.messageWrapper) return; + if (!this.refs.messagePanel) return; - var messageWrapper = this.refs.messageWrapper.getDOMNode(); + var messageWrapperScroll = this._getScrollNode(); if (this.state.paginating && !this.waiting_for_paginate) { - var heightGained = messageWrapper.scrollHeight - this.oldScrollHeight; - messageWrapper.scrollTop += heightGained; + var heightGained = messageWrapperScroll.scrollHeight - this.oldScrollHeight; + messageWrapperScroll.scrollTop += heightGained; this.oldScrollHeight = undefined; if (!this.fillSpace()) { this.setState({paginating: false}); } } else if (this.atBottom) { - messageWrapper.scrollTop = messageWrapper.scrollHeight; + messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight; if (this.state.numUnreadMessages !== 0) { this.setState({numUnreadMessages: 0}); } @@ -233,12 +287,12 @@ module.exports = { }, fillSpace: function() { - if (!this.refs.messageWrapper) return; - var messageWrapper = this.refs.messageWrapper.getDOMNode(); - if (messageWrapper.scrollTop < messageWrapper.clientHeight && this.state.room.oldState.paginationToken) { + if (!this.refs.messagePanel) return; + var messageWrapperScroll = this._getScrollNode(); + if (messageWrapperScroll.scrollTop < messageWrapperScroll.clientHeight && this.state.room.oldState.paginationToken) { this.setState({paginating: true}); - this.oldScrollHeight = messageWrapper.scrollHeight; + this.oldScrollHeight = messageWrapperScroll.scrollHeight; if (this.state.messageCap < this.state.room.timeline.length) { this.waiting_for_paginate = false; @@ -265,6 +319,13 @@ module.exports = { return false; }, + onResendAllClick: function() { + var eventsToResend = this._getUnsentMessages(this.state.room); + eventsToResend.forEach(function(event) { + Resend.resend(event); + }); + }, + onJoinButtonClicked: function(ev) { var self = this; MatrixClientPeg.get().joinRoom(this.props.roomId).then(function() { @@ -284,10 +345,10 @@ module.exports = { }, onMessageListScroll: function(ev) { - if (this.refs.messageWrapper) { - var messageWrapper = this.refs.messageWrapper.getDOMNode(); + if (this.refs.messagePanel) { + var messageWrapperScroll = this._getScrollNode(); var wasAtBottom = this.atBottom; - this.atBottom = messageWrapper.scrollHeight - messageWrapper.scrollTop <= messageWrapper.clientHeight; + this.atBottom = messageWrapperScroll.scrollHeight - messageWrapperScroll.scrollTop <= messageWrapperScroll.clientHeight + 1; if (this.atBottom && !wasAtBottom) { this.forceUpdate(); // remove unread msg count } @@ -350,8 +411,12 @@ module.exports = { self.setState({ upload: undefined }); - }).done(undefined, function() { - // display error message + }).done(undefined, function(error) { + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Failed to upload file", + description: error.toString() + }); }); }, @@ -377,6 +442,7 @@ module.exports = { room_events: { search_term: term, filter: filter, + order_by: "recent", event_context: { before_limit: 1, after_limit: 1, @@ -390,7 +456,11 @@ module.exports = { searchResults: data, }); }, function(error) { - // TODO: show dialog or something + var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Search failed", + description: error.toString() + }); }); }, @@ -408,7 +478,7 @@ module.exports = { var eventIds = Object.keys(results); // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? - var resultList = eventIds.map(function(key) { return results[key]; }).sort(function(a, b) { b.rank - a.rank }); + var resultList = eventIds.map(function(key) { return results[key]; }); // .sort(function(a, b) { b.rank - a.rank }); for (var i = 0; i < resultList.length; i++) { var ts1 = resultList[i].result.origin_server_ts; ret.push(
  • ); // Rank: {resultList[i].rank} @@ -472,7 +542,7 @@ module.exports = { } ret.unshift( -
  • +
  • ); if (dateSeparator) { ret.unshift(dateSeparator); @@ -567,5 +637,58 @@ module.exports = { uploadingRoomSettings: false, }); } + }, + + _collectEventNode: function(eventId, node) { + if (this.eventNodes == undefined) this.eventNodes = {}; + this.eventNodes[eventId] = node; + }, + + _indexForEventId(evId) { + for (var i = 0; i < this.state.room.timeline.length; ++i) { + if (evId == this.state.room.timeline[i].getId()) { + return i; + } + } + return null; + }, + + sendReadReceipt: function() { + if (!this.state.room) return; + var currentReadUpToEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId); + var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId); + + var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn(); + if (lastReadEventIndex === null) return; + + if (lastReadEventIndex > currentReadUpToEventIndex) { + MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]); + } + }, + + _getLastDisplayedEventIndexIgnoringOwn: function() { + if (this.eventNodes === undefined) return null; + + var messageWrapper = this.refs.messagePanel; + if (messageWrapper === undefined) return null; + var wrapperRect = ReactDOM.findDOMNode(messageWrapper).getBoundingClientRect(); + + for (var i = this.state.room.timeline.length-1; i >= 0; --i) { + var ev = this.state.room.timeline[i]; + + if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { + continue; + } + + var node = this.eventNodes[ev.getId()]; + if (!node) continue; + + var boundingRect = node.getBoundingClientRect(); + + if (boundingRect.bottom < wrapperRect.bottom) { + return i; + } + } + return null; } }; diff --git a/src/controllers/templates/Register.js b/src/controllers/templates/Register.js deleted file mode 100644 index 5f88d5905..000000000 --- a/src/controllers/templates/Register.js +++ /dev/null @@ -1,58 +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 extend = require('matrix-react-sdk/lib/extend'); -var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg'); -var BaseRegisterController = require('matrix-react-sdk/lib/controllers/templates/Register.js'); - -var RegisterController = {}; -extend(RegisterController, BaseRegisterController); - -RegisterController.onRegistered = function(user_id, access_token) { - MatrixClientPeg.replaceUsingAccessToken( - this.state.hs_url, this.state.is_url, user_id, access_token - ); - - this.setState({ - step: 'profile', - busy: true - }); - - var self = this; - var cli = MatrixClientPeg.get(); - cli.getProfileInfo(cli.credentials.userId).done(function(result) { - self.setState({ - avatarUrl: result.avatar_url, - busy: false - }); - }, - function(err) { - console.err(err); - self.setState({ - busy: false - }); - }); -}; - -RegisterController.onAccountReady = function() { - if (this.props.onLoggedIn) { - this.props.onLoggedIn(); - } -}; - -module.exports = RegisterController; diff --git a/src/skins/vector/css/atoms/MemberAvatar.css b/src/skins/vector/css/atoms/MemberAvatar.css index fc5fd60d2..e76c2ad10 100644 --- a/src/skins/vector/css/atoms/MemberAvatar.css +++ b/src/skins/vector/css/atoms/MemberAvatar.css @@ -15,7 +15,18 @@ limitations under the License. */ .mx_MemberAvatar { - z-index: 20; - border-radius: 20px; + /* commenting this out as it breaks on FF seemingly */ +/* position: relative; */ } +.mx_MemberAvatar_initial { + position: absolute; + color: #fff; + text-align: center; + speak: none; + pointer-events: none; +} + +.mx_MemberAvatar_image { + border-radius: 20px; +} diff --git a/src/skins/vector/views/organisms/CasLogin.js b/src/skins/vector/css/atoms/RoomAvatar.css similarity index 57% rename from src/skins/vector/views/organisms/CasLogin.js rename to src/skins/vector/css/atoms/RoomAvatar.css index ad9dbed95..70a61eeb0 100644 --- a/src/skins/vector/views/organisms/CasLogin.js +++ b/src/skins/vector/css/atoms/RoomAvatar.css @@ -14,22 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; +.mx_RoomAvatar { +} -var React = require('react'); - -var CasLoginController = require('matrix-react-sdk/lib/controllers/organisms/CasLogin'); - -module.exports = React.createClass({ - displayName: 'CasLogin', - mixins: [CasLoginController], - - render: function() { - return ( -
    - -
    - ); - }, - -}); +.mx_RoomAvatar_initial { + position: absolute; + color: #fff; + text-align: center; + font-weight: normal ! important; + speak: none; + pointer-events: none; +} \ No newline at end of file diff --git a/src/skins/vector/css/atoms/Spinner.css b/src/skins/vector/css/atoms/Spinner.css new file mode 100644 index 000000000..b2a04607f --- /dev/null +++ b/src/skins/vector/css/atoms/Spinner.css @@ -0,0 +1,32 @@ +/* +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_Spinner { + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + -webkit-justify-content: center; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + flex: 1; + -webkit-flex: 1; +} + +.mx_MatrixChat_middlePanel .mx_Spinner { + height: auto; +} \ No newline at end of file diff --git a/src/skins/vector/css/common.css b/src/skins/vector/css/common.css index a68d190d6..ba931b9cb 100644 --- a/src/skins/vector/css/common.css +++ b/src/skins/vector/css/common.css @@ -47,6 +47,14 @@ a:visited { color: #76cfa6; } +/* XXX: critical hack to GeminiScrollbar to allow them to work in FF 42 and Chrome 48. + Stop the scrollbar view from pushing out the container's overall sizing, which causes + flexbox to adapt to the new size and cause the view to keep growing. + */ +.gm-scrollbar-container .gm-scroll-view { + position: absolute; +} + .mx_ContextualMenu_background { position: fixed; top: 0; @@ -91,19 +99,9 @@ a:visited { margin: 0 auto; } -.mx_Dialog_background { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #000; - opacity: 0.2; - z-index: 2000; -} - .mx_Dialog_wrapper { position: fixed; + z-index: 4000; top: 0; left: 0; width: 100%; @@ -124,7 +122,7 @@ a:visited { background-color: #fff; color: #747474; text-align: center; - z-index: 2010; + z-index: 4010; font-weight: 300; font-size: 16px; position: relative; @@ -132,6 +130,16 @@ a:visited { max-width: 80%; } +.mx_Dialog_background { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #000; + opacity: 0.2; +} + .mx_Dialog_lightbox .mx_Dialog_background { opacity: 0.85; } diff --git a/src/skins/vector/css/gemini-scrollbar.css b/src/skins/vector/css/gemini-scrollbar.css new file mode 120000 index 000000000..4e3c83ba7 --- /dev/null +++ b/src/skins/vector/css/gemini-scrollbar.css @@ -0,0 +1 @@ +../../../../node_modules/react-gemini-scrollbar/node_modules/gemini-scrollbar/gemini-scrollbar.css \ No newline at end of file diff --git a/src/skins/vector/css/gfm.css b/src/skins/vector/css/gfm.css new file mode 120000 index 000000000..7940eacb5 --- /dev/null +++ b/src/skins/vector/css/gfm.css @@ -0,0 +1 @@ +../../../../node_modules/gfm.css/gfm.css \ No newline at end of file diff --git a/src/skins/vector/css/github.css b/src/skins/vector/css/github.css new file mode 120000 index 000000000..1ca3fc5a7 --- /dev/null +++ b/src/skins/vector/css/github.css @@ -0,0 +1 @@ +../../../../node_modules/highlight.js/styles/github.css \ No newline at end of file diff --git a/src/skins/vector/css/hide.css b/src/skins/vector/css/hide.css index 7d8ee3029..f84a35b31 100644 --- a/src/skins/vector/css/hide.css +++ b/src/skins/vector/css/hide.css @@ -1,4 +1,3 @@ -.mx_RoomDropTarget, .mx_RoomSettings_encrypt, .mx_CreateRoom_encrypt, .mx_RightPanel_filebutton diff --git a/src/skins/vector/css/molecules/EventTile.css b/src/skins/vector/css/molecules/EventTile.css index eb59711e8..697655e88 100644 --- a/src/skins/vector/css/molecules/EventTile.css +++ b/src/skins/vector/css/molecules/EventTile.css @@ -18,13 +18,13 @@ limitations under the License. max-width: 100%; clear: both; margin-top: 24px; - margin-left: 56px; + margin-left: 65px; } .mx_EventTile_avatar { padding-left: 18px; padding-right: 12px; - margin-left: -64px; + margin-left: -73px; margin-top: -4px; float: left; } @@ -49,7 +49,6 @@ limitations under the License. .mx_EventTile .mx_MessageTimestamp { color: #acacac; font-size: 12px; - float: right; } .mx_EventTile_line { @@ -66,6 +65,28 @@ limitations under the License. margin-right: 100px; } +/* Various markdown overrides */ + +.mx_MessageTile_content .markdown-body { + font-family: inherit ! important; + white-space: normal ! important; + line-height: inherit ! important; +} + +.mx_MessageTile_content .markdown-body h1, +.mx_MessageTile_content .markdown-body h2, +.mx_MessageTile_content .markdown-body h3, +.mx_MessageTile_content .markdown-body h4, +.mx_MessageTile_content .markdown-body h5, +.mx_MessageTile_content .markdown-body h6 +{ + font-family: inherit ! important; +} + +.mx_MessageTile_content .markdown-body a { + color: #76cfa6; +} + .mx_MessageTile_searchHighlight { background-color: #76cfa6; color: #fff; @@ -78,7 +99,7 @@ limitations under the License. } .mx_EventTile_notSent { - color: #f11; + color: #ddd; } .mx_EventTile_highlight { @@ -91,10 +112,18 @@ limitations under the License. .mx_EventTile_msgOption { float: right; + text-align: right; + z-index: 1; + position: relative; + width: 90px; + margin-right: 10px; + margin-top: -6px; } .mx_MessageTimestamp { + display: block; visibility: hidden; + text-align: right; } .mx_EventTile_last .mx_MessageTimestamp { @@ -107,9 +136,8 @@ limitations under the License. .mx_EventTile_editButton { position: absolute; - right: 1px; - top: 15px; - visibility: hidden; + display: inline-block; + visibility: hidden; } .mx_EventTile:hover .mx_EventTile_editButton { @@ -123,3 +151,21 @@ limitations under the License. .mx_EventTile.menu .mx_MessageTimestamp { visibility: visible; } + +.mx_EventTile_readAvatars { + position: relative; + display: inline-block; + width: 14px; + height: 14px; +} + +.mx_EventTile_readAvatars .mx_MemberAvatar { + position: absolute; + display: inline-block; +} + +.mx_EventTile_readAvatarRemainder { + color: #acacac; + font-size: 12px; + position: absolute; +} diff --git a/src/skins/vector/css/molecules/MNoticeTile.css b/src/skins/vector/css/molecules/MNoticeTile.css index 0a0db62ea..9fe5376a9 100644 --- a/src/skins/vector/css/molecules/MNoticeTile.css +++ b/src/skins/vector/css/molecules/MNoticeTile.css @@ -15,5 +15,6 @@ limitations under the License. */ .mx_MNoticeTile { + white-space: pre-wrap; opacity: 0.6; } diff --git a/src/skins/vector/css/molecules/MatrixToolbar.css b/src/skins/vector/css/molecules/MatrixToolbar.css index 99c282408..b545b1ad3 100644 --- a/src/skins/vector/css/molecules/MatrixToolbar.css +++ b/src/skins/vector/css/molecules/MatrixToolbar.css @@ -15,20 +15,40 @@ limitations under the License. */ .mx_MatrixToolbar { - text-align: center; - background-color: #ff0064; + background-color: #76cfa6; color: #fff; - font-weight: bold; - padding: 6px; + + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center; } -.mx_MatrixToolbar button { - margin-left: 12px; +.mx_MatrixToolbar_warning { + margin-left: 16px; + margin-right: 8px; + margin-top: -2px; +} + +.mx_MatrixToolbar_link +{ + color: #fff ! important; + text-decoration: underline ! important; + cursor: pointer; } .mx_MatrixToolbar_close { - float: right; - margin-top: 3px; - margin-right: 12px; + -webkit-flex: 1; + flex: 1; cursor: pointer; -} \ No newline at end of file + text-align: right; +} + +.mx_MatrixToolbar_close img { + display: block; + float: right; + margin-right: 10px; +} diff --git a/src/skins/vector/css/molecules/MessageComposer.css b/src/skins/vector/css/molecules/MessageComposer.css index 44e122762..3fb38c317 100644 --- a/src/skins/vector/css/molecules/MessageComposer.css +++ b/src/skins/vector/css/molecules/MessageComposer.css @@ -16,29 +16,25 @@ limitations under the License. .mx_MessageComposer_wrapper { max-width: 960px; - height: 70px; vertical-align: middle; margin: auto; - background-color: #fff; border-top: 2px solid #e1dddd; } .mx_MessageComposer_row { display: table-row; width: 100%; - height: 70px; } .mx_MessageComposer .mx_MessageComposer_avatar { display: table-cell; padding-left: 10px; - padding-right: 20px; - height: 70px; + padding-right: 28px; + vertical-align: middle; } -.mx_MessageComposer .mx_MessageComposer_avatar img { - margin-top: 18px; - border-radius: 20px; +.mx_MessageComposer .mx_MessageComposer_avatar .mx_MemberAvatar { + display: block; } .mx_MessageComposer_input { @@ -49,17 +45,18 @@ limitations under the License. } .mx_MessageComposer_input textarea { + display: block; font-size: 15px; width: 100%; - height: 1.2em; - padding-top: 0.7em; - padding-bottom: 0.7em; + padding: 0px; + margin-top: 6px; + margin-bottom: 6px; border: 0px; resize: none; outline: none; -webkit-box-shadow: none; -moz-box-shadow: none; - box-shadow: none; + box-shadow: none; /* needed for FF */ font-family: 'Myriad Pro', Helvetica, Arial, Sans-Serif; @@ -75,7 +72,8 @@ limitations under the License. } .mx_MessageComposer_upload, -.mx_MessageComposer_call { +.mx_MessageComposer_voicecall, +.mx_MessageComposer_videocall { display: table-cell; vertical-align: middle; padding-left: 10px; @@ -83,7 +81,12 @@ limitations under the License. cursor: pointer; } -.mx_MessageComposer_call { +.mx_MessageComposer_videocall { + padding-right: 10px; + padding-top: 4px; +} + +.mx_MessageComposer_voicecall { padding-right: 10px; padding-top: 4px; } diff --git a/src/skins/vector/css/molecules/RoomDropTarget.css b/src/skins/vector/css/molecules/RoomDropTarget.css index c42d44995..4eea49e15 100644 --- a/src/skins/vector/css/molecules/RoomDropTarget.css +++ b/src/skins/vector/css/molecules/RoomDropTarget.css @@ -16,12 +16,46 @@ limitations under the License. .mx_RoomDropTarget { font-size: 14px; - text-align: center; - margin-left: 8px; - margin-right: 8px; - padding-top: 16px; - padding-bottom: 16px; - background-color: #fbfbfb; - border: 1px dashed #d7d7d7; - border-radius: 8px; + margin-left: 10px; + margin-right: 15px; + padding-top: 5px; + padding-bottom: 5px; + border: 1px dashed #76cfa6; + color: #454545; + background-color: rgba(255,255,255,0.5); + border-radius: 4px; +} + +.collapsed .mx_RoomDropTarget { + margin-right: 10px; +} + +.mx_RoomDropTarget_placeholder { + padding-top: 1px; + padding-bottom: 1px; +} + +.mx_RoomDropTarget_avatar { + background-color: #fff; + border-radius: 24px; + width: 24px; + height: 24px; + float: left; + margin-left: 7px; + margin-right: 7px; +} + +.mx_RoomDropTarget_label { + position: relative; + margin-top: 3px; + line-height: 21px; + z-index: 1; +} + +.collapsed .mx_RoomDropTarget_avatar { + float: none; +} + +.collapsed .mx_RoomDropTarget_label { + display: none; } diff --git a/src/skins/vector/css/molecules/RoomHeader.css b/src/skins/vector/css/molecules/RoomHeader.css index 2eeda2417..5519c14de 100644 --- a/src/skins/vector/css/molecules/RoomHeader.css +++ b/src/skins/vector/css/molecules/RoomHeader.css @@ -33,6 +33,7 @@ limitations under the License. .mx_RoomHeader_leftRow { height: 48px; margin-top: 18px; + margin-left: -2px; -webkit-box-ordinal-group: 1; -moz-box-ordinal-group: 1; @@ -89,9 +90,9 @@ limitations under the License. .mx_RoomHeader_simpleHeader { line-height: 83px; - color: #76cfa6; - font-weight: 400; - font-size: 20px; + color: #454545; + font-size: 24px; + font-weight: bold; overflow: hidden; text-overflow: ellipsis; } @@ -101,9 +102,9 @@ limitations under the License. vertical-align: middle; height: 28px; color: #454545; - font-weight: 800; + font-weight: bold; font-size: 24px; - padding-left: 8px; + padding-left: 19px; padding-right: 16px; text-overflow: ellipsis; } @@ -153,7 +154,7 @@ limitations under the License. max-height: 38px; color: #454545; font-weight: 300; - padding-left: 8px; + padding-left: 19px; padding-right: 16px; overflow: hidden; text-overflow: ellipsis; diff --git a/src/skins/vector/css/molecules/RoomTile.css b/src/skins/vector/css/molecules/RoomTile.css index f2c1daadb..37d2e1b62 100644 --- a/src/skins/vector/css/molecules/RoomTile.css +++ b/src/skins/vector/css/molecules/RoomTile.css @@ -16,13 +16,13 @@ limitations under the License. .mx_RoomTile { cursor: pointer; - display: table-row; + /* This fixes wrapping of long room names, but breaks drag & drop previews */ + /* display: table-row; */ font-size: 14px; } .mx_RoomTile_avatar { display: table-cell; - background: #eaf5f0; padding-right: 8px; padding-top: 4px; padding-bottom: 2px; @@ -39,17 +39,16 @@ limitations under the License. .mx_RoomTile_name { display: table-cell; + width: 100%; vertical-align: middle; overflow: hidden; text-overflow: ellipsis; padding-right: 16px; - color: #454545; - opacity: 0.8; + color: rgba(69, 69, 69, 0.8); } .mx_RoomTile_invite { - opacity: 0.5; - font-weight: normal; + color: rgba(69, 69, 69, 0.5); } .collapsed .mx_RoomTile_name { @@ -105,16 +104,15 @@ limitations under the License. } .mx_RoomTile_unread, -.mx_RoomTile_highlight, -.mx_RoomTile_invited -{ +.mx_RoomTile_highlight { font-weight: bold; } -.mx_RoomTile_selected { +.mx_RoomTile_selected .mx_RoomTile_name { + color: #76cfa6 ! important; } -.mx_RoomTile.mx_RoomTile_selected { +.mx_RoomTile.mx_RoomTile_selected .mx_RoomTile_name { background: url('img/selected.png'); background-repeat: no-repeat; background-position: right center; diff --git a/src/skins/vector/css/molecules/RoomTooltip.css b/src/skins/vector/css/molecules/RoomTooltip.css index 604c6a56f..4e831d48c 100644 --- a/src/skins/vector/css/molecules/RoomTooltip.css +++ b/src/skins/vector/css/molecules/RoomTooltip.css @@ -21,7 +21,6 @@ limitations under the License. border-radius: 8px; background-color: #fff; z-index: 1000; - margin-top: 6px; left: 64px; padding: 6px; } diff --git a/src/skins/vector/css/organisms/LeftPanel.css b/src/skins/vector/css/organisms/LeftPanel.css index 67f00c358..37de0f0e5 100644 --- a/src/skins/vector/css/organisms/LeftPanel.css +++ b/src/skins/vector/css/organisms/LeftPanel.css @@ -34,16 +34,21 @@ limitations under the License. cursor: pointer; } -.mx_LeftPanel .mx_RoomList { +.mx_LeftPanel_callView { + +} + +.mx_LeftPanel .mx_RoomList_scrollbar { -webkit-box-ordinal-group: 1; -moz-box-ordinal-group: 1; -ms-flex-order: 1; -webkit-order: 1; order: 1; - overflow-y: auto; -webkit-flex: 1 1 0; flex: 1 1 0; + + overflow-y: auto; } .mx_LeftPanel .mx_BottomLeftMenu { @@ -53,8 +58,10 @@ limitations under the License. -webkit-order: 3; order: 3; - -webkit-flex: 0 0 126px; - flex: 0 0 126px; + -webkit-flex: 0 0 140px; + flex: 0 0 140px; + + background-color: rgba(118,207,166,0.19); } .mx_LeftPanel .mx_BottomLeftMenu .mx_RoomTile { @@ -62,7 +69,7 @@ limitations under the License. } .mx_LeftPanel .mx_BottomLeftMenu .mx_BottomLeftMenu_options { - margin-top: 12px; + margin-top: 17px; width: 100%; } diff --git a/src/skins/vector/css/organisms/RoomList.css b/src/skins/vector/css/organisms/RoomList.css index 34ebd1dbf..bb81686c3 100644 --- a/src/skins/vector/css/organisms/RoomList.css +++ b/src/skins/vector/css/organisms/RoomList.css @@ -16,13 +16,7 @@ limitations under the License. .mx_RoomList { padding-top: 24px; -} - -.mx_RoomList_invites, -.mx_RoomList_recents { - display: table; - table-layout: fixed; - width: 100%; + padding-bottom: 12px; } .mx_RoomList_expandButton { @@ -32,13 +26,9 @@ limitations under the License. padding-right: 12px; } -.mx_RoomList h2 { - text-transform: uppercase; - color: #3d3b39; - font-weight: 600; - font-size: 14px; - padding-left: 12px; - padding-right: 12px; - margin-top: 8px; - margin-bottom: 4px; +/* Evil hacky override until Chrome fixes drop and drag table cells + and we can correctly fix horizontal wrapping in the sidebar again */ +.mx_RoomList_scrollbar .gm-scroll-view { + overflow-x: hidden ! important; + overflow-y: scroll ! important; } diff --git a/src/skins/vector/css/organisms/RoomSubList.css b/src/skins/vector/css/organisms/RoomSubList.css new file mode 100644 index 000000000..57d23a383 --- /dev/null +++ b/src/skins/vector/css/organisms/RoomSubList.css @@ -0,0 +1,45 @@ +/* +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_RoomSubList { + display: table; + table-layout: fixed; + width: 100%; +} + +.mx_RoomSubList_bottommost { + /* XXX: this should really be 100% of the RoomList height, but can't seem to get at it */ + min-height: 400px; +} + +.mx_RoomSubList_label { + text-transform: uppercase; + color: #3d3b39; + font-weight: 600; + font-size: 14px; + padding-left: 12px; + padding-right: 12px; + margin-top: 8px; + margin-bottom: 4px; +} + +.mx_RoomSubList_chevron { + padding-left: 5px; +} + +.collapsed .mx_RoomSubList_chevron { + padding-left: 13px; +} diff --git a/src/skins/vector/css/organisms/RoomView.css b/src/skins/vector/css/organisms/RoomView.css index d564b0862..870640f12 100644 --- a/src/skins/vector/css/organisms/RoomView.css +++ b/src/skins/vector/css/organisms/RoomView.css @@ -125,11 +125,11 @@ limitations under the License. clear: both; } -.mx_RoomView_MessageList h2 { +.mx_RoomView_MessageList > h2 { clear: both; margin-top: 32px; margin-bottom: 8px; - margin-left: 54px; + margin-left: 63px; padding-bottom: 6px; border-bottom: 1px solid #eee; } @@ -158,18 +158,19 @@ limitations under the License. order: 4; width: 100%; - -webkit-flex: 0 0 36px; - flex: 0 0 36px; + -webkit-flex: 0 0 auto; + flex: 0 0 auto; } .mx_RoomView_statusAreaBox { max-width: 960px; margin: auto; + min-height: 36px; } .mx_RoomView_statusAreaBox_line { border-top: 1px solid #eee; - margin-left: 54px; + margin-left: 63px; height: 1px; } @@ -185,16 +186,44 @@ limitations under the License. vertical-align: middle; } +.mx_RoomView_connectionLostBar { + margin-top: 19px; + height: 58px; +} + +.mx_RoomView_connectionLostBar img { + padding-left: 10px; + padding-right: 22px; + vertical-align: middle; + float: left; +} + +.mx_RoomView_connectionLostBar_title { + color: #ff0064; +} + +.mx_RoomView_connectionLostBar_desc { + color: #454545; + font-size: 14px; + opacity: 0.5; +} + +.mx_RoomView_resend_link { + color: #454545 ! important; + text-decoration: underline ! important; + cursor: pointer; +} + .mx_RoomView_typingBar { margin-top: 10px; - margin-left: 54px; + margin-left: 63px; color: #4a4a4a; opacity: 0.5; } .mx_RoomView_typingImage { display: inline; - margin-left: -38px; + margin-left: -47px; margin-top: -4px; float: left; } @@ -207,14 +236,14 @@ limitations under the License. order: 5; width: 100%; - -webkit-flex: 0 0 70px; - flex: 0 0 70px; + -webkit-flex: 0; + flex: 0; margin-right: 2px; } .mx_RoomView_uploadProgressOuter { height: 4px; - margin-left: 54px; + margin-left: 63px; margin-top: -1px; } @@ -225,7 +254,7 @@ limitations under the License. .mx_RoomView_uploadFilename { margin-top: 5px; - margin-left: 56px; + margin-left: 65px; opacity: 0.5; color: #4a4a4a; } diff --git a/src/skins/vector/css/pages/MatrixChat.css b/src/skins/vector/css/pages/MatrixChat.css index f649aa245..2190e4960 100644 --- a/src/skins/vector/css/pages/MatrixChat.css +++ b/src/skins/vector/css/pages/MatrixChat.css @@ -14,6 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_MatrixChat_splash { + position: relative; + height: 100%; +} + +.mx_MatrixChat_splashButtons { + text-align: center; + width: 100%; + position: absolute; + bottom: 30px; +} + .mx_MatrixChat_wrapper { display: -webkit-box; display: -moz-box; @@ -35,7 +47,7 @@ limitations under the License. -webkit-order: 1; order: 1; - height: 21px; + height: 40px; } .mx_MatrixChat_toolbarShowing { @@ -71,8 +83,8 @@ limitations under the License. background-color: #eaf5f0; - -webkit-flex: 0 0 230px; - flex: 0 0 230px; + -webkit-flex: 0 0 210px; + flex: 0 0 210px; } .mx_MatrixChat .mx_LeftPanel.collapsed { @@ -87,8 +99,8 @@ limitations under the License. -webkit-order: 2; order: 2; - padding-left: 12px; - padding-right: 12px; + padding-left: 25px; + padding-right: 22px; background-color: #fff; -webkit-flex: 1; @@ -97,7 +109,8 @@ limitations under the License. /* XXX: Hack: apparently if you try to nest a flex-box * within a non-flex-box within a flex-box, the height * of the innermost element gets miscalculated if the - * parents are both auto. + * parents are both auto. Height has to be auto here + * for RoomView to correctly fit when the Toolbar is shown. * Ideally we'd launch straight into the RoomView at this * point, but instead we fudge it and make the middlePanel * flex itself. @@ -116,8 +129,8 @@ limitations under the License. -webkit-order: 3; order: 3; - -webkit-flex: 0 0 230px; - flex: 0 0 230px; + -webkit-flex: 0 0 235px; + flex: 0 0 235px; } .mx_MatrixChat .mx_RightPanel.collapsed { diff --git a/src/skins/vector/css/templates/Login.css b/src/skins/vector/css/templates/Login.css index 93cb7433a..11fba43fb 100644 --- a/src/skins/vector/css/templates/Login.css +++ b/src/skins/vector/css/templates/Login.css @@ -17,6 +17,18 @@ limitations under the License. .mx_Login { width: 100%; height: 100%; + + display: -webkit-box; + display: -moz-box; + display: -ms-flexbox; + display: -webkit-flex; + display: flex; + -webkit-align-items: center; + align-items: center; + -webkit-justify-content: center; + justify-content: center; + + overflow: auto; } .mx_Login h2 { @@ -28,8 +40,10 @@ limitations under the License. .mx_Login_box { width: 300px; + min-height: 450px; + padding-top: 50px; + padding-bottom: 50px; margin: auto; - padding-top: 100px; } .mx_Login_logo { diff --git a/src/skins/vector/img/cancel-black2.png b/src/skins/vector/img/cancel-black2.png new file mode 100644 index 000000000..a928c61b0 Binary files /dev/null and b/src/skins/vector/img/cancel-black2.png differ diff --git a/src/skins/vector/img/list-close.png b/src/skins/vector/img/list-close.png new file mode 100644 index 000000000..82b322f9d Binary files /dev/null and b/src/skins/vector/img/list-close.png differ diff --git a/src/skins/vector/img/list-open.png b/src/skins/vector/img/list-open.png new file mode 100644 index 000000000..f8c806319 Binary files /dev/null and b/src/skins/vector/img/list-open.png differ diff --git a/src/skins/vector/img/voice.png b/src/skins/vector/img/voice.png new file mode 100644 index 000000000..5ba765b0f Binary files /dev/null and b/src/skins/vector/img/voice.png differ diff --git a/src/skins/vector/img/warning.png b/src/skins/vector/img/warning.png new file mode 100644 index 000000000..c5553530a Binary files /dev/null and b/src/skins/vector/img/warning.png differ diff --git a/src/skins/vector/img/warning2.png b/src/skins/vector/img/warning2.png new file mode 100644 index 000000000..db0fd4a89 Binary files /dev/null and b/src/skins/vector/img/warning2.png differ diff --git a/src/skins/vector/skindex.js b/src/skins/vector/skindex.js index 69fe6117e..83a1284cb 100644 --- a/src/skins/vector/skindex.js +++ b/src/skins/vector/skindex.js @@ -65,13 +65,11 @@ skin['molecules.RoomTile'] = require('./views/molecules/RoomTile'); skin['molecules.RoomTooltip'] = require('./views/molecules/RoomTooltip'); skin['molecules.SearchBar'] = require('./views/molecules/SearchBar'); skin['molecules.SenderProfile'] = require('./views/molecules/SenderProfile'); -skin['molecules.ServerConfig'] = require('./views/molecules/ServerConfig'); skin['molecules.UnknownMessageTile'] = require('./views/molecules/UnknownMessageTile'); skin['molecules.UserSelector'] = require('./views/molecules/UserSelector'); skin['molecules.voip.CallView'] = require('./views/molecules/voip/CallView'); skin['molecules.voip.IncomingCallBox'] = require('./views/molecules/voip/IncomingCallBox'); skin['molecules.voip.VideoView'] = require('./views/molecules/voip/VideoView'); -skin['organisms.CasLogin'] = require('./views/organisms/CasLogin'); skin['organisms.CreateRoom'] = require('./views/organisms/CreateRoom'); skin['organisms.ErrorDialog'] = require('./views/organisms/ErrorDialog'); skin['organisms.LeftPanel'] = require('./views/organisms/LeftPanel'); @@ -82,12 +80,11 @@ skin['organisms.QuestionDialog'] = require('./views/organisms/QuestionDialog'); skin['organisms.RightPanel'] = require('./views/organisms/RightPanel'); skin['organisms.RoomDirectory'] = require('./views/organisms/RoomDirectory'); skin['organisms.RoomList'] = require('./views/organisms/RoomList'); +skin['organisms.RoomSubList'] = require('./views/organisms/RoomSubList'); skin['organisms.RoomView'] = require('./views/organisms/RoomView'); skin['organisms.UserSettings'] = require('./views/organisms/UserSettings'); skin['organisms.ViewSource'] = require('./views/organisms/ViewSource'); skin['pages.CompatibilityPage'] = require('./views/pages/CompatibilityPage'); skin['pages.MatrixChat'] = require('./views/pages/MatrixChat'); -skin['templates.Login'] = require('./views/templates/Login'); -skin['templates.Register'] = require('./views/templates/Register'); module.exports = skin; \ No newline at end of file diff --git a/src/skins/vector/views/atoms/MemberAvatar.js b/src/skins/vector/views/atoms/MemberAvatar.js index 69652e1a2..c719d70c5 100644 --- a/src/skins/vector/views/atoms/MemberAvatar.js +++ b/src/skins/vector/views/atoms/MemberAvatar.js @@ -40,10 +40,32 @@ module.exports = React.createClass({ }, render: function() { + // XXX: recalculates default avatar url constantly + if (this.state.imageUrl === this.defaultAvatarUrl(this.props.member)) { + var initial; + if (this.props.member.name[0]) + initial = this.props.member.name[0].toUpperCase(); + if (initial === '@' && this.props.member.name[1]) + initial = this.props.member.name[1].toUpperCase(); + + return ( + + + + + ); + } return ( - + width={this.props.width} height={this.props.height} + title={this.props.member.name} + {...this.props} + /> ); } }); diff --git a/src/skins/vector/views/atoms/RoomAvatar.js b/src/skins/vector/views/atoms/RoomAvatar.js index a1d87f7fa..bdd28bad5 100644 --- a/src/skins/vector/views/atoms/RoomAvatar.js +++ b/src/skins/vector/views/atoms/RoomAvatar.js @@ -43,13 +43,33 @@ module.exports = React.createClass({ render: function() { var style = { - maxWidth: this.props.width, - maxHeight: this.props.height, + width: this.props.width, + height: this.props.height, }; - return ( - - ); + + // XXX: recalculates fallback avatar constantly + if (this.state.imageUrl === this.getFallbackAvatar()) { + var initial; + if (this.props.room.name[0]) + initial = this.props.room.name[0].toUpperCase(); + if ((initial === '@' || initial === '#') && this.props.room.name[1]) + initial = this.props.room.name[1].toUpperCase(); + + return ( + + + + + ); + } + else { + return + } + } }); diff --git a/src/skins/vector/views/atoms/Spinner.js b/src/skins/vector/views/atoms/Spinner.js index 908f26785..6dfd0c41a 100644 --- a/src/skins/vector/views/atoms/Spinner.js +++ b/src/skins/vector/views/atoms/Spinner.js @@ -26,7 +26,7 @@ module.exports = React.createClass({ var h = this.props.h || 32; var imgClass = this.props.imgClassName || ""; return ( -
    +
    ); diff --git a/src/skins/vector/views/molecules/ChangeAvatar.js b/src/skins/vector/views/molecules/ChangeAvatar.js index 42c2d1fd4..7afac77fd 100644 --- a/src/skins/vector/views/molecules/ChangeAvatar.js +++ b/src/skins/vector/views/molecules/ChangeAvatar.js @@ -21,9 +21,6 @@ var React = require('react'); var sdk = require('matrix-react-sdk') var ChangeAvatarController = require('matrix-react-sdk/lib/controllers/molecules/ChangeAvatar') -var Loader = require("react-loader"); - - module.exports = React.createClass({ displayName: 'ChangeAvatar', mixins: [ChangeAvatarController], @@ -70,6 +67,7 @@ module.exports = React.createClass({
    ); case this.Phases.Uploading: + var Loader = sdk.getComponent("atoms.Spinner"); return ( ); diff --git a/src/skins/vector/views/molecules/ChangeDisplayName.js b/src/skins/vector/views/molecules/ChangeDisplayName.js index 1a094ec24..a10ba2a75 100644 --- a/src/skins/vector/views/molecules/ChangeDisplayName.js +++ b/src/skins/vector/views/molecules/ChangeDisplayName.js @@ -20,8 +20,6 @@ var React = require('react'); var sdk = require('matrix-react-sdk'); var ChangeDisplayNameController = require("matrix-react-sdk/lib/controllers/molecules/ChangeDisplayName"); -var Loader = require("react-loader"); - module.exports = React.createClass({ displayName: 'ChangeDisplayName', @@ -39,6 +37,7 @@ module.exports = React.createClass({ render: function() { if (this.state.busy) { + var Loader = sdk.getComponent("atoms.Spinner"); return ( ); diff --git a/src/skins/vector/views/molecules/ChangePassword.js b/src/skins/vector/views/molecules/ChangePassword.js index 004fed398..b1d8f28e6 100644 --- a/src/skins/vector/views/molecules/ChangePassword.js +++ b/src/skins/vector/views/molecules/ChangePassword.js @@ -19,17 +19,15 @@ limitations under the License. var React = require('react'); var ChangePasswordController = require('matrix-react-sdk/lib/controllers/molecules/ChangePassword') -var Loader = require("react-loader"); - module.exports = React.createClass({ displayName: 'ChangePassword', mixins: [ChangePasswordController], onClickChange: function() { - var old_password = this.refs.old_input.getDOMNode().value; - var new_password = this.refs.new_input.getDOMNode().value; - var confirm_password = this.refs.confirm_input.getDOMNode().value; + var old_password = this.refs.old_input.value; + var new_password = this.refs.new_input.value; + var confirm_password = this.refs.confirm_input.value; if (new_password != confirm_password) { this.setState({ state: this.Phases.Error, @@ -64,6 +62,7 @@ module.exports = React.createClass({ ); case this.Phases.Uploading: + var Loader = sdk.getComponent("atoms.Spinner"); return (
    diff --git a/src/skins/vector/views/molecules/EventTile.js b/src/skins/vector/views/molecules/EventTile.js index c5cb81951..236e7b1fd 100644 --- a/src/skins/vector/views/molecules/EventTile.js +++ b/src/skins/vector/views/molecules/EventTile.js @@ -17,15 +17,28 @@ limitations under the License. 'use strict'; var React = require('react'); +var ReactDom = require('react-dom'); var classNames = require("classnames"); var sdk = require('matrix-react-sdk') +var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg') var EventTileController = require('matrix-react-sdk/lib/controllers/molecules/EventTile') var ContextualMenu = require('../../../../ContextualMenu'); var TextForEvent = require('matrix-react-sdk/lib/TextForEvent'); +var Velociraptor = require('../../../../Velociraptor'); +require('../../../../VelocityBounce'); + +var bounce = false; +try { + if (global.localStorage) { + bounce = global.localStorage.getItem('avatar_bounce') == 'true'; + } +} catch (e) { +} + var eventTileTypes = { 'm.room.message': 'molecules.MessageTile', 'm.room.member' : 'molecules.EventAsTextTile', @@ -36,6 +49,8 @@ var eventTileTypes = { 'm.room.topic' : 'molecules.EventAsTextTile', }; +var MAX_READ_AVATARS = 5; + module.exports = React.createClass({ displayName: 'EventTile', mixins: [EventTileController], @@ -52,7 +67,7 @@ module.exports = React.createClass({ }, getInitialState: function() { - return {menu: false}; + return {menu: false, allReadAvatars: false}; }, onEditClicked: function(e) { @@ -72,6 +87,127 @@ module.exports = React.createClass({ this.setState({menu: true}); }, + toggleAllReadAvatars: function() { + this.setState({ + allReadAvatars: !this.state.allReadAvatars + }); + }, + + getReadAvatars: function() { + var avatars = []; + + var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + + if (!room) return []; + + var myUserId = MatrixClientPeg.get().credentials.userId; + + // get list of read receipts, sorted most recent first + var receipts = room.getReceiptsForEvent(this.props.mxEvent).filter(function(r) { + return r.type === "m.read" && r.userId != myUserId; + }).sort(function(r1, r2) { + return r2.data.ts - r1.data.ts; + }); + + var MemberAvatar = sdk.getComponent('atoms.MemberAvatar'); + + var left = 0; + + var reorderTransitionOpts = { + duration: 100, + easing: 'easeOut' + }; + + for (var i = 0; i < receipts.length; ++i) { + var member = room.getMember(receipts[i].userId); + + // Using react refs here would mean both getting Velociraptor to expose + // them and making them scoped to the whole RoomView. Not impossible, but + // getElementById seems simpler at least for a first cut. + var oldAvatarDomNode = document.getElementById('mx_readAvatar'+member.userId); + var startStyles = []; + var enterTransitionOpts = []; + var oldNodeTop = -15; // For avatars that weren't on screen, act as if they were just off the top + if (oldAvatarDomNode) { + oldNodeTop = oldAvatarDomNode.getBoundingClientRect().top; + } + + if (this.readAvatarNode) { + var topOffset = oldNodeTop - this.readAvatarNode.getBoundingClientRect().top; + + if (oldAvatarDomNode && oldAvatarDomNode.style.left !== '0px') { + var leftOffset = oldAvatarDomNode.style.left; + // start at the old height and in the old h pos + startStyles.push({ top: topOffset, left: leftOffset }); + enterTransitionOpts.push(reorderTransitionOpts); + } + + // then shift to the rightmost column, + // and then it will drop down to its resting position + startStyles.push({ top: topOffset, left: '0px' }); + enterTransitionOpts.push({ + duration: bounce ? Math.min(Math.log(Math.abs(topOffset)) * 200, 3000) : 300, + easing: bounce ? 'easeOutBounce' : 'easeOutCubic', + }); + } + + var style = { + left: left+'px', + top: '0px', + visibility: ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) ? 'visible' : 'hidden' + }; + + //console.log("i = " + i + ", MAX_READ_AVATARS = " + MAX_READ_AVATARS + ", allReadAvatars = " + this.state.allReadAvatars + " visibility = " + style.visibility); + + // add to the start so the most recent is on the end (ie. ends up rightmost) + avatars.unshift( + + ); + // TODO: we keep the extra read avatars in the dom to make animation simpler + // we could optimise this to reduce the dom size. + if (i < MAX_READ_AVATARS - 1 || this.state.allReadAvatars) { // XXX: where does this -1 come from? is it to make the max'th avatar animate properly? + left -= 15; + } + } + var editButton; + if (!this.state.allReadAvatars) { + var remainder = receipts.length - MAX_READ_AVATARS; + var remText; + if (i >= MAX_READ_AVATARS - 1) left -= 15; + if (remainder > 0) { + remText = { remainder }+ + ; + left -= 15; + } + editButton = ( + + ); + } + + return + { editButton } + { remText } + + { avatars } + + ; + }, + + collectReadAvatarNode: function(node) { + this.readAvatarNode = ReactDom.findDOMNode(node); + }, + render: function() { var MessageTimestamp = sdk.getComponent('atoms.MessageTimestamp'); var SenderProfile = sdk.getComponent('molecules.SenderProfile'); @@ -100,18 +236,14 @@ module.exports = React.createClass({ menu: this.state.menu, }); var timestamp = - var editButton = ( - - ); var aux = null; if (msgtype === 'm.image') aux = "sent an image"; else if (msgtype === 'm.video') aux = "sent a video"; else if (msgtype === 'm.file') aux = "uploaded a file"; + var readAvatars = this.getReadAvatars(); + var avatar, sender; if (!this.props.continuation) { if (this.props.mxEvent.sender) { @@ -127,11 +259,13 @@ module.exports = React.createClass({ } return (
    +
    + { timestamp } + { readAvatars } +
    { avatar } { sender }
    - { timestamp } - { editButton }
    diff --git a/src/skins/vector/views/molecules/MFileTile.js b/src/skins/vector/views/molecules/MFileTile.js index f7e8991f9..9180bd6b8 100644 --- a/src/skins/vector/views/molecules/MFileTile.js +++ b/src/skins/vector/views/molecules/MFileTile.js @@ -30,15 +30,25 @@ module.exports = React.createClass({ var content = this.props.mxEvent.getContent(); var cli = MatrixClientPeg.get(); - return ( - - + var httpUrl = cli.mxcUrlToHttp(content.url); + var text = this.presentableTextForFile(content); + + if (httpUrl) { + return ( + + + + ); + } else { + var extra = text ? ': '+text : ''; + return + Invalid file{extra} - ); + } }, }); diff --git a/src/skins/vector/views/molecules/MImageTile.js b/src/skins/vector/views/molecules/MImageTile.js index 0667dabd1..febf38999 100644 --- a/src/skins/vector/views/molecules/MImageTile.js +++ b/src/skins/vector/views/molecules/MImageTile.js @@ -63,6 +63,34 @@ module.exports = React.createClass({ } }, + _isGif: function() { + var content = this.props.mxEvent.getContent(); + return (content && content.info && content.info.mimetype === "image/gif"); + }, + + onImageEnter: function(e) { + if (!this._isGif()) { + return; + } + var imgElement = e.target; + imgElement.src = MatrixClientPeg.get().mxcUrlToHttp( + this.props.mxEvent.getContent().url + ); + }, + + onImageLeave: function(e) { + if (!this._isGif()) { + return; + } + var imgElement = e.target; + imgElement.src = this._getThumbUrl(); + }, + + _getThumbUrl: function() { + var content = this.props.mxEvent.getContent(); + return MatrixClientPeg.get().mxcUrlToHttp(content.url, 480, 360); + }, + render: function() { var content = this.props.mxEvent.getContent(); var cli = MatrixClientPeg.get(); @@ -73,18 +101,36 @@ module.exports = React.createClass({ var imgStyle = {}; if (thumbHeight) imgStyle['height'] = thumbHeight; - return ( - - - {content.body} - - - - ); + + + ); + } else if (content.body) { + return ( + + Image '{content.body}' cannot be displayed. + + ); + } else { + return ( + + This image cannot be displayed. + + ); + } }, }); diff --git a/src/skins/vector/views/molecules/MNoticeTile.js b/src/skins/vector/views/molecules/MNoticeTile.js index a0cedb1dd..c905d3be2 100644 --- a/src/skins/vector/views/molecules/MNoticeTile.js +++ b/src/skins/vector/views/molecules/MNoticeTile.js @@ -17,67 +17,34 @@ limitations under the License. 'use strict'; var React = require('react'); -var sanitizeHtml = require('sanitize-html'); +var HtmlUtils = require('../../../../HtmlUtils'); var MNoticeTileController = require('matrix-react-sdk/lib/controllers/molecules/MNoticeTile') -var allowedAttributes = sanitizeHtml.defaults.allowedAttributes; -allowedAttributes['font'] = ['color']; -var sanitizeHtmlParams = { - allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'font' ]), - allowedAttributes: allowedAttributes, -}; - module.exports = React.createClass({ displayName: 'MNoticeTile', mixins: [MNoticeTileController], - // FIXME: this entire class is copy-pasted from MTextTile :( + componentDidMount: function() { + if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") + HtmlUtils.highlightDom(this.getDOMNode()); + }, + + componentDidUpdate: function() { + if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") + HtmlUtils.highlightDom(this.getDOMNode()); + }, + + shouldComponentUpdate: function(nextProps) { + // exploit that events are immutable :) + return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || + nextProps.searchTerm !== this.props.searchTerm); + }, + + // XXX: fix horrible duplication with MTextTile render: function() { var content = this.props.mxEvent.getContent(); - var originalBody = content.body; - var body; - - if (this.props.searchTerm) { - var lastOffset = 0; - var bodyList = []; - var k = 0; - var offset; - - // XXX: rather than searching for the search term in the body, - // we should be looking at the match delimiters returned by the FTS engine - if (content.format === "org.matrix.custom.html") { - var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); - var safeSearchTerm = sanitizeHtml(this.props.searchTerm, sanitizeHtmlParams); - while ((offset = safeBody.indexOf(safeSearchTerm, lastOffset)) >= 0) { - // FIXME: we need to apply the search highlighting to only the text elements of HTML, which means - // hooking into the sanitizer parser rather than treating it as a string. Otherwise - // the act of highlighting a or whatever will break the HTML badly. - bodyList.push(); - bodyList.push(); - lastOffset = offset + safeSearchTerm.length; - } - bodyList.push(); - } - else { - while ((offset = originalBody.indexOf(this.props.searchTerm, lastOffset)) >= 0) { - bodyList.push({ originalBody.substring(lastOffset, offset) }); - bodyList.push({ this.props.searchTerm }); - lastOffset = offset + this.props.searchTerm.length; - } - bodyList.push({ originalBody.substring(lastOffset) }); - } - body = bodyList; - } - else { - if (content.format === "org.matrix.custom.html") { - var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); - body = ; - } - else { - body = originalBody; - } - } + var body = HtmlUtils.bodyToHtml(content, this.props.searchTerm); return ( diff --git a/src/skins/vector/views/molecules/MTextTile.js b/src/skins/vector/views/molecules/MTextTile.js index 12bafa37b..8352ae5c5 100644 --- a/src/skins/vector/views/molecules/MTextTile.js +++ b/src/skins/vector/views/molecules/MTextTile.js @@ -17,67 +17,33 @@ limitations under the License. 'use strict'; var React = require('react'); -var sanitizeHtml = require('sanitize-html'); +var HtmlUtils = require('../../../../HtmlUtils'); var MTextTileController = require('matrix-react-sdk/lib/controllers/molecules/MTextTile') -var allowedAttributes = sanitizeHtml.defaults.allowedAttributes; -allowedAttributes['font'] = ['color']; -var sanitizeHtmlParams = { - allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'font' ]), - allowedAttributes: allowedAttributes, -}; - module.exports = React.createClass({ displayName: 'MTextTile', mixins: [MTextTileController], - // FIXME: this entire class is copy-pasted from MTextTile :( + componentDidMount: function() { + if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") + HtmlUtils.highlightDom(this.getDOMNode()); + }, + + componentDidUpdate: function() { + if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") + HtmlUtils.highlightDom(this.getDOMNode()); + }, + + shouldComponentUpdate: function(nextProps) { + // exploit that events are immutable :) + return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || + nextProps.searchTerm !== this.props.searchTerm); + }, + render: function() { var content = this.props.mxEvent.getContent(); - var originalBody = content.body; - var body; - - if (this.props.searchTerm) { - var lastOffset = 0; - var bodyList = []; - var k = 0; - var offset; - - // XXX: rather than searching for the search term in the body, - // we should be looking at the match delimiters returned by the FTS engine - if (content.format === "org.matrix.custom.html") { - var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); - var safeSearchTerm = sanitizeHtml(this.props.searchTerm, sanitizeHtmlParams); - while ((offset = safeBody.indexOf(safeSearchTerm, lastOffset)) >= 0) { - // FIXME: we need to apply the search highlighting to only the text elements of HTML, which means - // hooking into the sanitizer parser rather than treating it as a string. Otherwise - // the act of highlighting a or whatever will break the HTML badly. - bodyList.push(); - bodyList.push(); - lastOffset = offset + safeSearchTerm.length; - } - bodyList.push(); - } - else { - while ((offset = originalBody.indexOf(this.props.searchTerm, lastOffset)) >= 0) { - bodyList.push({ originalBody.substring(lastOffset, offset) }); - bodyList.push({ this.props.searchTerm }); - lastOffset = offset + this.props.searchTerm.length; - } - bodyList.push({ originalBody.substring(lastOffset) }); - } - body = bodyList; - } - else { - if (content.format === "org.matrix.custom.html") { - var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); - body = ; - } - else { - body = originalBody; - } - } + var body = HtmlUtils.bodyToHtml(content, this.props.searchTerm); return ( diff --git a/src/skins/vector/views/molecules/MatrixToolbar.js b/src/skins/vector/views/molecules/MatrixToolbar.js index 4a299f141..5b613f563 100644 --- a/src/skins/vector/views/molecules/MatrixToolbar.js +++ b/src/skins/vector/views/molecules/MatrixToolbar.js @@ -28,12 +28,19 @@ module.exports = React.createClass({ Notifier.setToolbarHidden(true); }, + onClick: function() { + var Notifier = sdk.getComponent('organisms.Notifier'); + Notifier.setEnabled(true); + }, + render: function() { - var EnableNotificationsButton = sdk.getComponent("atoms.EnableNotificationsButton"); return (
    - You are not receiving desktop notifications. -
    + /!\ +
    + You are not receiving desktop notifications. Enable them now +
    +
    ); } diff --git a/src/skins/vector/views/molecules/MemberInfo.js b/src/skins/vector/views/molecules/MemberInfo.js index 5f8e806d2..24fa1e91a 100644 --- a/src/skins/vector/views/molecules/MemberInfo.js +++ b/src/skins/vector/views/molecules/MemberInfo.js @@ -17,7 +17,6 @@ limitations under the License. 'use strict'; var React = require('react'); -var Loader = require("../atoms/Spinner"); var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg'); var sdk = require('matrix-react-sdk') @@ -47,6 +46,7 @@ module.exports = React.createClass({ } if (this.state.creatingRoom) { + var Loader = sdk.getComponent("atoms.Spinner"); spinner = ; } diff --git a/src/skins/vector/views/molecules/MessageComposer.js b/src/skins/vector/views/molecules/MessageComposer.js index 25f69bda3..96001bed6 100644 --- a/src/skins/vector/views/molecules/MessageComposer.js +++ b/src/skins/vector/views/molecules/MessageComposer.js @@ -28,8 +28,12 @@ module.exports = React.createClass({ displayName: 'MessageComposer', mixins: [MessageComposerController], + onInputClick: function(ev) { + this.refs.textarea.focus(); + }, + onUploadClick: function(ev) { - this.refs.uploadInput.getDOMNode().click(); + this.refs.uploadInput.click(); }, onUploadFileSelected: function(ev) { @@ -38,7 +42,7 @@ module.exports = React.createClass({ if (files && files.length > 0) { this.props.uploadFile(files[0]); } - this.refs.uploadInput.getDOMNode().value = null; + this.refs.uploadInput.value = null; }, onCallClick: function(ev) { @@ -49,6 +53,14 @@ module.exports = React.createClass({ }); }, + onVoiceCallClick: function(ev) { + dis.dispatch({ + action: 'place_call', + type: 'voice', + room_id: this.props.room.roomId + }); + }, + render: function() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -60,15 +72,18 @@ module.exports = React.createClass({
    -
    -