diff --git a/package.json b/package.json index 2da0968345..0b5a18168f 100644 --- a/package.json +++ b/package.json @@ -23,13 +23,16 @@ "filesize": "^3.1.2", "flux": "^2.0.3", "glob": "^5.0.14", + "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", "marked": "^0.3.5", "matrix-js-sdk": "https://github.com/matrix-org/matrix-js-sdk.git#develop", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^0.14.2", - "react-dom": "^0.14.2" + "react-dom": "^0.14.2", + "sanitize-html": "^1.11.1", + "velocity-animate": "^1.2.3" }, "//deps": "The loader packages are here because webpack in a project that depends on us needs them in this package's node_modules folder", "//depsbuglink": "https://github.com/webpack/webpack/issues/1472", diff --git a/src/ContextualMenu.js b/src/ContextualMenu.js new file mode 100644 index 0000000000..a7b1849e18 --- /dev/null +++ b/src/ContextualMenu.js @@ -0,0 +1,82 @@ +/* +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'); + +// Shamelessly ripped off Modal.js. There's probably a better way +// of doing reusable widgets like dialog boxes & menus where we go and +// pass in a custom control as the actual body. + +module.exports = { + ContextualMenuContainerId: "mx_ContextualMenu_Container", + + getOrCreateContainer: function() { + var container = document.getElementById(this.ContextualMenuContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = this.ContextualMenuContainerId; + document.body.appendChild(container); + } + + return container; + }, + + createMenu: function (Element, props) { + var self = this; + + var closeMenu = function() { + ReactDOM.unmountComponentAtNode(self.getOrCreateContainer()); + + if (props && props.onFinished) props.onFinished.apply(null, arguments); + }; + + var position = { + top: props.top - 20, + }; + + var chevron = null; + if (props.left) { + chevron = + position.left = props.left + 8; + } else { + chevron = + position.right = props.right + 8; + } + + var className = 'mx_ContextualMenu_wrapper'; + + // FIXME: If a menu uses getDefaultProps it clobbers the onFinished + // property set here so you can't close the menu from a button click! + var menu = ( +
+
+ {chevron} + +
+
+
+ ); + + ReactDOM.render(menu, this.getOrCreateContainer()); + + return {close: closeMenu}; + }, +}; diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js new file mode 100644 index 0000000000..824f59ab20 --- /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/Velociraptor.js b/src/Velociraptor.js new file mode 100644 index 0000000000..d973a17f7f --- /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 0000000000..c85aa254fa --- /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/controllers/molecules/RoomSettings.js b/src/components/views/elements/ProgressBar.js similarity index 53% rename from src/controllers/molecules/RoomSettings.js rename to src/components/views/elements/ProgressBar.js index 3c0682d09a..bab6a701dd 100644 --- a/src/controllers/molecules/RoomSettings.js +++ b/src/components/views/elements/ProgressBar.js @@ -14,16 +14,25 @@ See the License for the specific language governing permissions and limitations under the License. */ +'use strict'; + var React = require('react'); -module.exports = { +module.exports = React.createClass({ + displayName: 'ProgressBar', propTypes: { - room: React.PropTypes.object.isRequired, + value: React.PropTypes.number, + max: React.PropTypes.number }, - getInitialState: function() { - return { - power_levels_changed: false + render: function() { + // Would use an HTML5 progress tag but if that doesn't animate if you + // use the HTML attributes rather than styles + var progressStyle = { + width: ((this.props.value / this.props.max) * 100)+"%" }; + return ( +
+ ); } -}; +}); \ No newline at end of file diff --git a/src/controllers/molecules/UserSelector.js b/src/components/views/elements/UserSelector.js similarity index 56% rename from src/controllers/molecules/UserSelector.js rename to src/components/views/elements/UserSelector.js index 67a56163fa..ea04de59a9 100644 --- a/src/controllers/molecules/UserSelector.js +++ b/src/components/views/elements/UserSelector.js @@ -18,7 +18,9 @@ limitations under the License. var React = require('react'); -module.exports = { +module.exports = React.createClass({ + displayName: 'UserSelector', + propTypes: { onChange: React.PropTypes.func, selected_users: React.PropTypes.arrayOf(React.PropTypes.string), @@ -42,4 +44,26 @@ module.exports = { return e != user_id; })); }, -}; + + onAddUserId: function() { + this.addUser(this.refs.user_id_input.value); + this.refs.user_id_input.value = ""; + }, + + render: function() { + var self = this; + return ( +
+
    + {this.props.selected_users.map(function(user_id, i) { + return
  • {user_id} - X
  • + })} +
+ + +
+ ); + } +}); diff --git a/src/components/views/messages/Event.js b/src/components/views/messages/Event.js new file mode 100644 index 0000000000..2fb2917541 --- /dev/null +++ b/src/components/views/messages/Event.js @@ -0,0 +1,276 @@ +/* +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 classNames = require("classnames"); + +var sdk = require('../../../index'); +var MatrixClientPeg = require('../../../MatrixClientPeg') +var TextForEvent = require('../../../TextForEvent'); + +var ContextualMenu = require('../../../ContextualMenu'); +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': 'messages.Message', + 'm.room.member' : 'messages.TextualEvent', + 'm.call.invite' : 'messages.TextualEvent', + 'm.call.answer' : 'messages.TextualEvent', + 'm.call.hangup' : 'messages.TextualEvent', + 'm.room.name' : 'messages.TextualEvent', + 'm.room.topic' : 'messages.TextualEvent', +}; + +var MAX_READ_AVATARS = 5; + +module.exports = React.createClass({ + displayName: 'Event', + + statics: { + haveTileForEvent: function(e) { + if (eventTileTypes[e.getType()] == undefined) return false; + if (eventTileTypes[e.getType()] == 'messages.TextualEvent') { + return TextForEvent.textForEvent(e) !== ''; + } else { + return true; + } + } + }, + + getInitialState: function() { + return {menu: false, allReadAvatars: false}; + }, + + shouldHighlight: function() { + var actions = MatrixClientPeg.get().getPushActionsForEvent(this.props.mxEvent); + if (!actions || !actions.tweaks) { return false; } + return actions.tweaks.highlight; + }, + + onEditClicked: function(e) { + var MessageContextMenu = sdk.getComponent('molecules.MessageContextMenu'); + var buttonRect = e.target.getBoundingClientRect() + var x = buttonRect.right; + var y = buttonRect.top + (e.target.height / 2); + var self = this; + ContextualMenu.createMenu(MessageContextMenu, { + mxEvent: this.props.mxEvent, + left: x, + top: y, + onFinished: function() { + self.setState({menu: false}); + } + }); + 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('avatars.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('messages.MessageTimestamp'); + var SenderProfile = sdk.getComponent('molecules.SenderProfile'); + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + + var content = this.props.mxEvent.getContent(); + var msgtype = content.msgtype; + + var EventTileType = sdk.getComponent(eventTileTypes[this.props.mxEvent.getType()]); + // This shouldn't happen: the caller should check we support this type + // before trying to instantiate us + if (!EventTileType) { + throw new Error("Event type not supported"); + } + + var classes = classNames({ + mx_EventTile: true, + mx_EventTile_sending: ['sending', 'queued'].indexOf( + this.props.mxEvent.status + ) !== -1, + mx_EventTile_notSent: this.props.mxEvent.status == 'not_sent', + mx_EventTile_highlight: this.shouldHighlight(), + mx_EventTile_continuation: this.props.continuation, + mx_EventTile_last: this.props.last, + mx_EventTile_contextual: this.props.contextual, + menu: this.state.menu, + }); + var timestamp = + + 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) { + avatar = ( +
+ +
+ ); + } + if (EventTileType.needsSenderProfile()) { + sender = ; + } + } + return ( +
+
+ { timestamp } + { readAvatars } +
+ { avatar } + { sender } +
+ +
+
+ ); + }, +}); diff --git a/src/controllers/molecules/MEmoteTile.js b/src/components/views/messages/MEmoteMessage.js similarity index 59% rename from src/controllers/molecules/MEmoteTile.js rename to src/components/views/messages/MEmoteMessage.js index d32d8ae911..26e29363cd 100644 --- a/src/controllers/molecules/MEmoteTile.js +++ b/src/components/views/messages/MEmoteMessage.js @@ -16,15 +16,29 @@ limitations under the License. 'use strict'; +var React = require('react'); var linkify = require('linkifyjs'); var linkifyElement = require('linkifyjs/element'); -var linkifyMatrix = require('../../linkify-matrix'); +var linkifyMatrix = require('../../../linkify-matrix'); linkifyMatrix(linkify); -module.exports = { +module.exports = React.createClass({ + displayName: 'MEmoteMessage', + componentDidMount: function() { linkifyElement(this.refs.content, linkifyMatrix.options); - } -}; + }, + + render: function() { + var mxEvent = this.props.mxEvent; + var content = mxEvent.getContent(); + var name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); + return ( + + * {name} {content.body} + + ); + }, +}); diff --git a/src/controllers/molecules/MFileTile.js b/src/components/views/messages/MFileMessage.js similarity index 54% rename from src/controllers/molecules/MFileTile.js rename to src/components/views/messages/MFileMessage.js index 13b2e41ca5..93e366a2cd 100644 --- a/src/controllers/molecules/MFileTile.js +++ b/src/components/views/messages/MFileMessage.js @@ -16,9 +16,13 @@ limitations under the License. 'use strict'; +var React = require('react'); var filesize = require('filesize'); +var MatrixClientPeg = require('../../../MatrixClientPeg'); + +module.exports = React.createClass({ + displayName: 'MFileMessage', -module.exports = { presentableTextForFile: function(content) { var linkText = 'Attachment'; if (content.body && content.body.length > 0) { @@ -39,6 +43,31 @@ module.exports = { linkText += ' (' + additionals.join(', ') + ')'; } return linkText; - } -}; + }, + render: function() { + var content = this.props.mxEvent.getContent(); + var cli = MatrixClientPeg.get(); + + 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/components/views/messages/MImageMessage.js b/src/components/views/messages/MImageMessage.js new file mode 100644 index 0000000000..dee5c37c1e --- /dev/null +++ b/src/components/views/messages/MImageMessage.js @@ -0,0 +1,136 @@ +/* +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 filesize = require('filesize'); + +var MatrixClientPeg = require('../../../MatrixClientPeg'); +var Modal = require('../../../Modal'); +var sdk = require('../../../index'); + +module.exports = React.createClass({ + displayName: 'MImageMessage', + + thumbHeight: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { + if (!fullWidth || !fullHeight) { + // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even + // log this because it's spammy + return undefined; + } + if (fullWidth < thumbWidth && fullHeight < thumbHeight) { + // no scaling needs to be applied + return fullHeight; + } + var widthMulti = thumbWidth / fullWidth; + var heightMulti = thumbHeight / fullHeight; + if (widthMulti < heightMulti) { + // width is the dominant dimension so scaling will be fixed on that + return Math.floor(widthMulti * fullHeight); + } + else { + // height is the dominant dimension so scaling will be fixed on that + return Math.floor(heightMulti * fullHeight); + } + }, + + onClick: function(ev) { + if (ev.button == 0 && !ev.metaKey) { + ev.preventDefault(); + var content = this.props.mxEvent.getContent(); + var httpUrl = MatrixClientPeg.get().mxcUrlToHttp(content.url); + var ImageView = sdk.getComponent("elements.ImageView"); + Modal.createDialog(ImageView, { + src: httpUrl, + width: content.info.w, + height: content.info.h, + mxEvent: this.props.mxEvent, + }, "mx_Dialog_lightbox"); + } + }, + + _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(); + + var thumbHeight = null; + if (content.info) thumbHeight = this.thumbHeight(content.info.w, content.info.h, 480, 360); + + var imgStyle = {}; + if (thumbHeight) imgStyle['height'] = thumbHeight; + + var thumbUrl = this._getThumbUrl(); + if (thumbUrl) { + 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/components/views/messages/MNoticeMessage.js b/src/components/views/messages/MNoticeMessage.js new file mode 100644 index 0000000000..3a89d1ff6a --- /dev/null +++ b/src/components/views/messages/MNoticeMessage.js @@ -0,0 +1,59 @@ +/* +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 linkify = require('linkifyjs'); +var linkifyElement = require('linkifyjs/element'); +var linkifyMatrix = require('../../../linkify-matrix.js'); +linkifyMatrix(linkify); +var HtmlUtils = require('../../../HtmlUtils'); + +module.exports = React.createClass({ + displayName: 'MNoticeMessage', + + componentDidMount: function() { + linkifyElement(this.refs.content, linkifyMatrix.options); + + 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 body = HtmlUtils.bodyToHtml(content, this.props.searchTerm); + + return ( + + { body } + + ); + }, +}); + diff --git a/src/components/views/messages/MRoomMemberEvent.js b/src/components/views/messages/MRoomMemberEvent.js new file mode 100644 index 0000000000..6e73519f2e --- /dev/null +++ b/src/components/views/messages/MRoomMemberEvent.js @@ -0,0 +1,52 @@ +/* +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('../../../index'); +var TextForEvent = require('../../../TextForEvent'); + +module.exports = React.createClass({ + displayName: 'MRoomMemberEvent', + + getMemberEventText: function() { + return TextForEvent.textForEvent(this.props.mxEvent); + }, + + render: function() { + // XXX: for now, just cheekily borrow the css from message tile... + var timestamp = this.props.last ? : null; + var text = this.getMemberEventText(); + if (!text) return
; + var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + return ( +
+
+ +
+ { timestamp } + + + { text } + +
+ ); + }, +}); + diff --git a/src/components/views/messages/MTextMessage.js b/src/components/views/messages/MTextMessage.js new file mode 100644 index 0000000000..d3b337cbc1 --- /dev/null +++ b/src/components/views/messages/MTextMessage.js @@ -0,0 +1,59 @@ +/* +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 HtmlUtils = require('../../../HtmlUtils'); +var linkify = require('linkifyjs'); +var linkifyElement = require('linkifyjs/element'); +var linkifyMatrix = require('../../../linkify-matrix'); + +linkifyMatrix(linkify); + +module.exports = React.createClass({ + displayName: 'MTextMessage', + + componentDidMount: function() { + linkifyElement(this.refs.content, linkifyMatrix.options); + + 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 body = HtmlUtils.bodyToHtml(content, this.props.searchTerm); + + return ( + + { body } + + ); + }, +}); + diff --git a/src/components/views/messages/MVideoMessage.js b/src/components/views/messages/MVideoMessage.js new file mode 100644 index 0000000000..5771ed2172 --- /dev/null +++ b/src/components/views/messages/MVideoMessage.js @@ -0,0 +1,83 @@ +/* +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 filesize = require('filesize'); + +var MatrixClientPeg = require('../../../MatrixClientPeg'); +var Modal = require('../../../Modal'); +var sdk = require('../../../index'); + +module.exports = React.createClass({ + displayName: 'MVideoMessage', + + thumbScale: function(fullWidth, fullHeight, thumbWidth, thumbHeight) { + if (!fullWidth || !fullHeight) { + // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even + // log this because it's spammy + return undefined; + } + if (fullWidth < thumbWidth && fullHeight < thumbHeight) { + // no scaling needs to be applied + return fullHeight; + } + var widthMulti = thumbWidth / fullWidth; + var heightMulti = thumbHeight / fullHeight; + if (widthMulti < heightMulti) { + // width is the dominant dimension so scaling will be fixed on that + return widthMulti; + } + else { + // height is the dominant dimension so scaling will be fixed on that + return heightMulti; + } + }, + + render: function() { + var content = this.props.mxEvent.getContent(); + var cli = MatrixClientPeg.get(); + + var height = null; + var width = null; + var poster = null; + var preload = "metadata"; + if (content.info) { + var scale = this.thumbScale(content.info.w, content.info.h, 480, 360); + if (scale) { + width = Math.floor(content.info.w * scale); + height = Math.floor(content.info.h * scale); + } + + if (content.info.thumbnail_url) { + poster = cli.mxcUrlToHttp(content.info.thumbnail_url); + preload = "none"; + } + } + + + + return ( + + + + ); + }, +}); diff --git a/src/components/views/messages/Message.js b/src/components/views/messages/Message.js new file mode 100644 index 0000000000..fa74a8e137 --- /dev/null +++ b/src/components/views/messages/Message.js @@ -0,0 +1,52 @@ +/* +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('../../../index'); + +module.exports = React.createClass({ + displayName: 'Message', + + statics: { + needsSenderProfile: function() { + return true; + } + }, + + render: function() { + var UnknownMessageTile = sdk.getComponent('messages.UnknownMessage'); + + var tileTypes = { + 'm.text': sdk.getComponent('messages.MTextMessage'), + 'm.notice': sdk.getComponent('messages.MNoticeMessage'), + 'm.emote': sdk.getComponent('messages.MEmoteMessage'), + 'm.image': sdk.getComponent('messages.MImageMessage'), + 'm.file': sdk.getComponent('messages.MFileMessage'), + 'm.video': sdk.getComponent('messages.MVideoMessage') + }; + + var content = this.props.mxEvent.getContent(); + var msgtype = content.msgtype; + var TileType = UnknownMessageTile; + if (msgtype && tileTypes[msgtype]) { + TileType = tileTypes[msgtype]; + } + + return ; + }, +}); diff --git a/src/controllers/molecules/MessageComposer.js b/src/components/views/messages/MessageComposer.js similarity index 85% rename from src/controllers/molecules/MessageComposer.js rename to src/components/views/messages/MessageComposer.js index 237c710395..869e9f7614 100644 --- a/src/controllers/molecules/MessageComposer.js +++ b/src/components/views/messages/MessageComposer.js @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - +var React = require("react"); var marked = require("marked"); marked.setOptions({ renderer: new marked.Renderer(), @@ -25,12 +25,12 @@ marked.setOptions({ smartLists: true, smartypants: false }); -var MatrixClientPeg = require("../../MatrixClientPeg"); -var SlashCommands = require("../../SlashCommands"); -var Modal = require("../../Modal"); -var sdk = require('../../index'); +var MatrixClientPeg = require("../../../MatrixClientPeg"); +var SlashCommands = require("../../../SlashCommands"); +var Modal = require("../../../Modal"); +var sdk = require('../../../index'); -var dis = require("../../dispatcher"); +var dis = require("../../../dispatcher"); var KeyCode = { ENTER: 13, BACKSPACE: 8, @@ -58,10 +58,11 @@ function mdownToHtml(mdown) { return html; } -module.exports = { - oldScrollHeight: 0, +module.exports = React.createClass({ + displayName: 'MessageComposer', componentWillMount: function() { + this.oldScrollHeight = 0; this.markdownEnabled = MARKDOWN_ENABLED; this.tabStruct = { completing: false, @@ -501,7 +502,69 @@ module.exports = { clearTimeout(this.typingTimeout); this.typingTimeout = null; } + }, + onInputClick: function(ev) { + this.refs.textarea.focus(); + }, + + onUploadClick: function(ev) { + this.refs.uploadInput.click(); + }, + + onUploadFileSelected: function(ev) { + var files = ev.target.files; + // MessageComposer shouldn't have to rely on it's parent passing in a callback to upload a file + if (files && files.length > 0) { + this.props.uploadFile(files[0]); + } + this.refs.uploadInput.value = null; + }, + + onCallClick: function(ev) { + dis.dispatch({ + action: 'place_call', + type: ev.shiftKey ? "screensharing" : "video", + room_id: this.props.room.roomId + }); + }, + + 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'}; + var MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); + return ( +
+
+
+
+ +
+
+
+ cancel_button =
Cancel
+ save_button =
Save Changes
+ } else { + // + name = +
+
{ this.props.room.name }
+
+ +
+
+ if (topic) topic_el =
{ topic.getContent().topic }
; + } + + var roomAvatar = null; + if (this.props.room) { + roomAvatar = ( + + ); + } + + var zoom_button, video_button, voice_button; + if (activeCall) { + if (activeCall.type == "video") { + zoom_button = ( +
+ Fullscreen +
+ ); + } + video_button = +
+ Video call +
; + voice_button = +
+ VoIP call +
; + } + + header = +
+
+
+ { roomAvatar } +
+
+ { name } + { topic_el } +
+
+ {call_buttons} + {cancel_button} + {save_button} +
+ { video_button } + { voice_button } + { zoom_button } +
+ Search +
+
+
+ } + + return ( +
+ { header } +
+ ); + }, +}); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js new file mode 100644 index 0000000000..eb9bfd90c8 --- /dev/null +++ b/src/components/views/rooms/RoomSettings.js @@ -0,0 +1,237 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var React = require('react'); +var MatrixClientPeg = require('../../../MatrixClientPeg'); +var sdk = require('../../../index'); + +module.exports = React.createClass({ + displayName: 'RoomSettings', + + propTypes: { + room: React.PropTypes.object.isRequired, + }, + + getInitialState: function() { + return { + power_levels_changed: false + }; + }, + + getTopic: function() { + return this.refs.topic.value; + }, + + getJoinRules: function() { + return this.refs.is_private.checked ? "invite" : "public"; + }, + + getHistoryVisibility: function() { + return this.refs.share_history.checked ? "shared" : "invited"; + }, + + getPowerLevels: function() { + if (!this.state.power_levels_changed) return undefined; + + var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); + power_levels = power_levels.getContent(); + + var new_power_levels = { + ban: parseInt(this.refs.ban.value), + kick: parseInt(this.refs.kick.value), + redact: parseInt(this.refs.redact.value), + invite: parseInt(this.refs.invite.value), + events_default: parseInt(this.refs.events_default.value), + state_default: parseInt(this.refs.state_default.value), + users_default: parseInt(this.refs.users_default.value), + users: power_levels.users, + events: power_levels.events, + }; + + return new_power_levels; + }, + + onPowerLevelsChanged: function() { + this.setState({ + power_levels_changed: true + }); + }, + + render: function() { + var ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); + + var topic = this.props.room.currentState.getStateEvents('m.room.topic', ''); + if (topic) topic = topic.getContent().topic; + + var join_rule = this.props.room.currentState.getStateEvents('m.room.join_rules', ''); + if (join_rule) join_rule = join_rule.getContent().join_rule; + + var history_visibility = this.props.room.currentState.getStateEvents('m.room.history_visibility', ''); + if (history_visibility) history_visibility = history_visibility.getContent().history_visibility; + + var power_levels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); + + var events_levels = power_levels.events || {}; + + if (power_levels) { + power_levels = power_levels.getContent(); + + var ban_level = parseInt(power_levels.ban); + var kick_level = parseInt(power_levels.kick); + var redact_level = parseInt(power_levels.redact); + var invite_level = parseInt(power_levels.invite || 0); + var send_level = parseInt(power_levels.events_default || 0); + var state_level = parseInt(power_levels.state_default || 0); + var default_user_level = parseInt(power_levels.users_default || 0); + + if (power_levels.ban == undefined) ban_level = 50; + if (power_levels.kick == undefined) kick_level = 50; + if (power_levels.redact == undefined) redact_level = 50; + + var user_levels = power_levels.users || {}; + + var user_id = MatrixClientPeg.get().credentials.userId; + + var current_user_level = user_levels[user_id]; + if (current_user_level == undefined) current_user_level = default_user_level; + + var power_level_level = events_levels["m.room.power_levels"]; + if (power_level_level == undefined) { + power_level_level = state_level; + } + + var can_change_levels = current_user_level >= power_level_level; + } else { + var ban_level = 50; + var kick_level = 50; + var redact_level = 50; + var invite_level = 0; + var send_level = 0; + var state_level = 0; + var default_user_level = 0; + + var user_levels = []; + var events_levels = []; + + var current_user_level = 0; + + var power_level_level = 0; + + var can_change_levels = false; + } + + var room_avatar_level = parseInt(power_levels.state_default || 0); + if (events_levels['m.room.avatar'] !== undefined) { + room_avatar_level = events_levels['m.room.avatar']; + } + var can_set_room_avatar = current_user_level >= room_avatar_level; + + var change_avatar; + if (can_set_room_avatar) { + change_avatar =
+

Room Icon

+ +
; + } + + var banned = this.props.room.getMembersWithMembership("ban"); + + return ( +
+