diff --git a/src/controllers/molecules/MessageTile.js b/header similarity index 85% rename from src/controllers/molecules/MessageTile.js rename to header index 7f3416d6db..fd88ee27f7 100644 --- a/src/controllers/molecules/MessageTile.js +++ b/header @@ -13,11 +13,3 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - -'use strict'; - -var MatrixClientPeg = require("../../MatrixClientPeg"); - -module.exports = { -}; - diff --git a/package.json b/package.json index 2da20805c1..9c2c645ea2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "reskindex": "./reskindex.js" }, "scripts": { + "reskindex": "reskindex -h header", "build": "babel src -d lib --source-maps", "start": "babel src -w -d lib --source-maps", "clean": "rimraf lib", @@ -23,19 +24,25 @@ "filesize": "^3.1.2", "flux": "^2.0.3", "glob": "^5.0.14", + "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", - "matrix-js-sdk": "^0.3.0", + "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", + "react-gemini-scrollbar": "^2.0.1", + "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", "devDependencies": { "babel": "^5.8.23", - "rimraf": "^2.4.3", "json-loader": "^0.5.3", + "require-json": "0.0.1", + "rimraf": "^2.4.3", "source-map-loader": "^0.1.5" } } diff --git a/reskindex.js b/reskindex.js index e028979122..66dfc39f73 100755 --- a/reskindex.js +++ b/reskindex.js @@ -8,40 +8,13 @@ var args = require('optimist').argv; var header = args.h || args.header; -if (args._.length == 0) { - console.log("No skin given"); - process.exit(1); -} +var componentsDir = path.join('src', 'components'); -var skin = args._[0]; +var componentIndex = path.join('src', 'component-index.js'); -try { - fs.accessSync(path.join('src', 'skins', skin), fs.F_OK); -} catch (e) { - console.log("Skin "+skin+" not found: "+e); - process.exit(1); -} +var packageJson = JSON.parse(fs.readFileSync('./package.json')); -var skinfoFile = path.join('src', 'skins', skin, 'skinfo.json'); - -try { - fs.accessSync(skinfoFile, fs.F_OK); -} catch (e) { - console.log("Skin "+skin+" has no skinfo.json"); - process.exit(1); -} - -try { - fs.accessSync(path.join('src', 'skins', skin, 'views'), fs.F_OK); -} catch (e) { - console.log("Skin "+skin+" has no views directory"); - process.exit(1); -} - -var skindex = path.join('src', 'skins', skin, 'skindex.js'); -var viewsDir = path.join('src', 'skins', skin, 'views'); - -var strm = fs.createWriteStream(skindex); +var strm = fs.createWriteStream(componentIndex); if (header) { strm.write(fs.readFileSync(header)); @@ -55,29 +28,21 @@ strm.write(" * so you'd just be trying to swim upstream like a salmon.\n"); strm.write(" * You are not a salmon.\n"); strm.write(" */\n\n"); -var mySkinfo = JSON.parse(fs.readFileSync(skinfoFile, "utf8")); +if (packageJson['matrix-react-parent']) { + strm.write("module.exports.components = require('"+packageJson['matrix-react-parent']+"/lib/component-index').components;\n\n"); +} else { + strm.write("module.exports.components = {};\n"); +} -strm.write("var skin = {};\n"); -strm.write('\n'); - -var files = glob.sync('**/*.js', {cwd: viewsDir}); +var files = glob.sync('**/*.js', {cwd: componentsDir}); for (var i = 0; i < files.length; ++i) { var file = files[i].replace('.js', ''); - var module = (file.replace(/\//g, '.')); - strm.write("skin['"+module+"'] = require('./views/"+file+"');\n"); + var moduleName = (file.replace(/\//g, '.')); + + strm.write("module.exports.components['"+moduleName+"'] = require('./components/"+file+"');"); + strm.write('\n'); strm.uncork(); } -strm.write("\n"); - -if (mySkinfo.baseSkin) { - strm.write("module.exports = require('"+mySkinfo.baseSkin+"');"); - strm.write("var extend = require('matrix-react-sdk/lib/extend');\n"); - strm.write("extend(module.exports, skin);\n"); -} else { - strm.write("module.exports = skin;"); -} - strm.end(); - diff --git a/src/Avatar.js b/src/Avatar.js new file mode 100644 index 0000000000..c919630f96 --- /dev/null +++ b/src/Avatar.js @@ -0,0 +1,49 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var MatrixClientPeg = require('./MatrixClientPeg'); + +module.exports = { + avatarUrlForMember: function(member, width, height, resizeMethod) { + var url = member.getAvatarUrl( + MatrixClientPeg.get().getHomeserverUrl(), + width, + height, + resizeMethod, + false, + false + ); + if (!url) { + // member can be null here currently since on invites, the JS SDK + // does not have enough info to build a RoomMember object for + // the inviter. + url = this.defaultAvatarUrlForString(member ? member.userId : ''); + } + return url; + }, + + defaultAvatarUrlForString: function(s) { + var images = [ '76cfa6', '50e2c2', 'f4c371' ]; + var total = 0; + for (var i = 0; i < s.length; ++i) { + total += s.charCodeAt(i); + } + return 'img/' + images[total % images.length] + '.png'; + } +} + diff --git a/src/CallHandler.js b/src/CallHandler.js index b3af0e8337..187449924f 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -56,12 +56,12 @@ var Modal = require('./Modal'); var sdk = require('./index'); var Matrix = require("matrix-js-sdk"); var dis = require("./dispatcher"); -var Modulator = require("./Modulator"); global.mxCalls = { //room_id: MatrixCall }; var calls = global.mxCalls; +var ConferenceHandler = null; function play(audioId) { // TODO: Attach an invisible element for this instead @@ -115,7 +115,7 @@ function _setCallListeners(call) { _setCallState(call, call.roomId, "busy"); pause("ringbackAudio"); play("busyAudio"); - var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { title: "Call Timeout", description: "The remote side failed to pick up." @@ -173,7 +173,7 @@ function _onAction(payload) { console.error("Unknown conf call type: %s", payload.type); } } - var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); switch (payload.action) { case 'place_call': @@ -202,7 +202,7 @@ function _onAction(payload) { var members = room.getJoinedMembers(); if (members.length <= 1) { - var ErrorDialog = sdk.getComponent("organisms.ErrorDialog"); + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { description: "You cannot place a call with yourself." }); @@ -227,7 +227,7 @@ function _onAction(payload) { break; case 'place_conference_call': console.log("Place conference call in %s", payload.room_id); - if (!Modulator.hasConferenceHandler()) { + if (!ConferenceHandler) { Modal.createDialog(ErrorDialog, { description: "Conference calls are not supported in this client" }); @@ -239,7 +239,6 @@ function _onAction(payload) { }); } else { - var ConferenceHandler = Modulator.getConferenceHandler(); ConferenceHandler.createNewMatrixCall( MatrixClientPeg.get(), payload.room_id ).done(function(call) { @@ -295,8 +294,7 @@ var callHandler = { var call = module.exports.getCall(roomId); if (call) return call; - if (Modulator.hasConferenceHandler()) { - var ConferenceHandler = Modulator.getConferenceHandler(); + if (ConferenceHandler) { call = ConferenceHandler.getConferenceCallForRoom(roomId); } if (call) return call; @@ -317,6 +315,10 @@ var callHandler = { } } return null; + }, + + setConferenceHandler: function(confHandler) { + ConferenceHandler = confHandler; } }; // Only things in here which actually need to be global are the diff --git a/src/ContentMessages.js b/src/ContentMessages.js index eba3011917..094eff18d9 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -18,6 +18,10 @@ limitations under the License. var q = require('q'); var extend = require('./extend'); +var dis = require('./dispatcher'); +var MatrixClientPeg = require('./MatrixClientPeg'); +var sdk = require('./index'); +var Modal = require('./Modal'); function infoForImageFile(imageFile) { var deferred = q.defer(); @@ -48,39 +52,108 @@ function infoForImageFile(imageFile) { return deferred.promise; } -function sendContentToRoom(file, roomId, matrixClient) { - var content = { - body: file.name, - info: { - size: file.size, +class ContentMessages { + constructor() { + this.inprogress = []; + this.nextId = 0; + } + + sendContentToRoom(file, roomId, matrixClient) { + var content = { + body: file.name, + info: { + size: file.size, + } + }; + + // if we have a mime type for the file, add it to the message metadata + if (file.type) { + content.info.mimetype = file.type; } - }; - // if we have a mime type for the file, add it to the message metadata - if (file.type) { - content.info.mimetype = file.type; - } - - var def = q.defer(); - if (file.type.indexOf('image/') == 0) { - content.msgtype = 'm.image'; - infoForImageFile(file).then(function(imageInfo) { - extend(content.info, imageInfo); + var def = q.defer(); + if (file.type.indexOf('image/') == 0) { + content.msgtype = 'm.image'; + infoForImageFile(file).then(function(imageInfo) { + extend(content.info, imageInfo); + def.resolve(); + }); + } else { + content.msgtype = 'm.file'; def.resolve(); + } + + var upload = { + fileName: file.name, + roomId: roomId, + total: 0, + loaded: 0 + }; + this.inprogress.push(upload); + dis.dispatch({action: 'upload_started'}); + + var self = this; + return def.promise.then(function() { + upload.promise = matrixClient.uploadContent(file); + return upload.promise; + }).progress(function(ev) { + if (ev) { + upload.total = ev.total; + upload.loaded = ev.loaded; + dis.dispatch({action: 'upload_progress', upload: upload}); + } + }).then(function(url) { + dis.dispatch({action: 'upload_finished', upload: upload}); + content.url = url; + return matrixClient.sendMessage(roomId, content); + }, function(err) { + dis.dispatch({action: 'upload_failed', upload: upload}); + if (!upload.canceled) { + var desc = "The file '"+upload.fileName+"' failed to upload."; + if (err.http_status == 413) { + desc = "The file '"+upload.fileName+"' exceeds this home server's size limit for uploads"; + } + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createDialog(ErrorDialog, { + title: "Upload Failed", + description: desc + }); + } + }).finally(function() { + var inprogressKeys = Object.keys(self.inprogress); + for (var i = 0; i < self.inprogress.length; ++i) { + var k = inprogressKeys[i]; + if (self.inprogress[k].promise === upload.promise) { + self.inprogress.splice(k, 1); + break; + } + } }); - } else { - content.msgtype = 'm.file'; - def.resolve(); } - return def.promise.then(function() { - return matrixClient.uploadContent(file); - }).then(function(url) { - content.url = url; - return matrixClient.sendMessage(roomId, content); - }); + getCurrentUploads() { + return this.inprogress; + } + + cancelUpload(promise) { + var inprogressKeys = Object.keys(this.inprogress); + var upload; + for (var i = 0; i < this.inprogress.length; ++i) { + var k = inprogressKeys[i]; + if (this.inprogress[k].promise === promise) { + upload = this.inprogress[k]; + break; + } + } + if (upload) { + upload.canceled = true; + MatrixClientPeg.get().cancelUpload(upload.promise); + } + } } -module.exports = { - sendContentToRoom: sendContentToRoom -}; +if (global.mx_ContentMessage === undefined) { + global.mx_ContentMessage = new ContentMessages(); +} + +module.exports = global.mx_ContentMessage; 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/DateUtils.js b/src/DateUtils.js new file mode 100644 index 0000000000..fe363586ab --- /dev/null +++ b/src/DateUtils.js @@ -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. +*/ + +'use strict'; + +var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +var months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +module.exports = { + formatDate: function(date) { + // date.toLocaleTimeString is completely system dependent. + // just go 24h for now + function pad(n) { + return (n < 10 ? '0' : '') + n; + } + + var now = new Date(); + if (date.toDateString() === now.toDateString()) { + return pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { + return days[date.getDay()] + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + else if (now.getFullYear() === date.getFullYear()) { + return days[date.getDay()] + ", " + months[date.getMonth()] + " " + (date.getDay()+1) + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + else { + return days[date.getDay()] + ", " + months[date.getMonth()] + " " + (date.getDay()+1) + " " + date.getFullYear() + " " + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + } +} + diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js new file mode 100644 index 0000000000..7a3cdd277b --- /dev/null +++ b/src/HtmlUtils.js @@ -0,0 +1,149 @@ +/* +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. + 'del', // for markdown + '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 = { + _applyHighlights: function(safeSnippet, highlights, html, k) { + var lastOffset = 0; + var offset; + var nodes = []; + + // XXX: when highlighting HTML, synapse performs the search on the plaintext body, + // but we're attempting to apply the highlights here to the HTML body. This is + // never going to end well - we really should be hooking into the sanitzer HTML + // parser to only attempt to highlight text nodes to avoid corrupting tags. + // If and when this happens, we'll probably have to split his method in two between + // HTML and plain-text highlighting. + + var safeHighlight = html ? sanitizeHtml(highlights[0], sanitizeHtmlParams) : highlights[0]; + while ((offset = safeSnippet.indexOf(safeHighlight, lastOffset)) >= 0) { + // handle preamble + if (offset > lastOffset) { + nodes = nodes.concat(this._applySubHighlightsInRange(safeSnippet, lastOffset, offset, highlights, html, k)); + k += nodes.length; + } + + // do highlight + if (html) { + nodes.push(); + } + else { + nodes.push({ safeHighlight }); + } + + lastOffset = offset + safeHighlight.length; + } + + // handle postamble + if (lastOffset != safeSnippet.length) { + nodes = nodes.concat(this._applySubHighlightsInRange(safeSnippet, lastOffset, undefined, highlights, html, k)); + k += nodes.length; + } + return nodes; + }, + + _applySubHighlightsInRange: function(safeSnippet, lastOffset, offset, highlights, html, k) { + var nodes = []; + if (highlights[1]) { + // recurse into this range to check for the next set of highlight matches + var subnodes = this._applyHighlights( safeSnippet.substring(lastOffset, offset), highlights.slice(1), html, k ); + nodes = nodes.concat(subnodes); + k += subnodes.length; + } + else { + // no more highlights to be found, just return the unhighlighted string + if (html) { + nodes.push(); + } + else { + nodes.push({ safeSnippet.substring(lastOffset, offset) }); + } + } + return nodes; + }, + + bodyToHtml: function(content, highlights) { + var originalBody = content.body; + var body; + var k = 0; + + if (highlights && highlights.length > 0) { + var bodyList = []; + + if (content.format === "org.matrix.custom.html") { + var safeBody = sanitizeHtml(content.formatted_body, sanitizeHtmlParams); + bodyList = this._applyHighlights(safeBody, highlights, true, k); + } + else { + bodyList = this._applyHighlights(originalBody, highlights, true, k); + } + 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/Modulator.js b/src/Modulator.js deleted file mode 100644 index 72fcc14d89..0000000000 --- a/src/Modulator.js +++ /dev/null @@ -1,111 +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. -*/ - -/** - * The modulator stores 'modules': classes that provide - * functionality and are not React UI components. - * Modules go into named slots, eg. a conference calling - * module goes into the 'conference' slot. If two modules - * that use the same slot are loaded, this is considered - * to be an error. - * - * There are some module slots that the react SDK knows - * about natively: these have explicit getters. - * - * A module must define: - * - 'slot' (string): The name of the slot it goes into - * and may define: - * - 'start' (function): Called on module load - * - 'stop' (function): Called on module unload - */ -class Modulator { - constructor() { - this.modules = {}; - } - - getModule(name) { - var m = this.getModuleOrNull(name); - if (m === null) { - throw new Error("No such module: "+name); - } - return m; - } - - getModuleOrNull(name) { - if (this.modules == {}) { - throw new Error( - "Attempted to get a module before a skin has been loaded."+ - "This is probably because a component has called "+ - "getModule at the root level." - ); - } - var module = this.modules[name]; - if (module) { - return module; - } - return null; - } - - hasModule(name) { - var m = this.getModuleOrNull(name); - return m !== null; - } - - loadModule(moduleObject) { - if (!moduleObject.slot) { - throw new Error( - "Attempted to load something that is not a module "+ - "(does not have a slot name)" - ); - } - if (this.modules[moduleObject.slot] !== undefined) { - throw new Error( - "Cannot load module: slot '"+moduleObject.slot+"' is occupied!" - ); - } - this.modules[moduleObject.slot] = moduleObject; - } - - reset() { - var keys = Object.keys(this.modules); - for (var i = 0; i < keys.length; ++i) { - var k = keys[i]; - var m = this.modules[k]; - - if (m.stop) m.stop(); - } - this.modules = {}; - } - - // *********** - // known slots - // *********** - - getConferenceHandler() { - return this.getModule('conference'); - } - - hasConferenceHandler() { - return this.hasModule('conference'); - } -} - -// Define one Modulator globally (see Skinner.js) -if (global.mxModulator === undefined) { - global.mxModulator = new Modulator(); -} -module.exports = global.mxModulator; - diff --git a/src/controllers/organisms/Notifier.js b/src/Notifier.js similarity index 53% rename from src/controllers/organisms/Notifier.js rename to src/Notifier.js index 8fb62abe40..a35c3bb1ee 100644 --- a/src/controllers/organisms/Notifier.js +++ b/src/Notifier.js @@ -16,8 +16,10 @@ limitations under the License. 'use strict'; -var MatrixClientPeg = require("../../MatrixClientPeg"); -var dis = require("../../dispatcher"); +var MatrixClientPeg = require("./MatrixClientPeg"); +var TextForEvent = require('./TextForEvent'); +var Avatar = require('./Avatar'); +var dis = require("./dispatcher"); /* * Dispatches: @@ -27,16 +29,85 @@ var dis = require("../../dispatcher"); * } */ -module.exports = { +var Notifier = { + + notificationMessageForEvent: function(ev) { + return TextForEvent.textForEvent(ev); + }, + + displayNotification: function(ev, room) { + if (!global.Notification || global.Notification.permission != 'granted') { + return; + } + if (global.document.hasFocus()) { + return; + } + + var msg = this.notificationMessageForEvent(ev); + if (!msg) return; + + var title; + if (!ev.sender || room.name == ev.sender.name) { + title = room.name; + // notificationMessageForEvent includes sender, + // but we already have the sender here + if (ev.getContent().body) msg = ev.getContent().body; + } else if (ev.getType() == 'm.room.member') { + // context is all in the message here, we don't need + // to display sender info + title = room.name; + } else if (ev.sender) { + title = ev.sender.name + " (" + room.name + ")"; + // notificationMessageForEvent includes sender, + // but we've just out sender in the title + if (ev.getContent().body) msg = ev.getContent().body; + } + + var avatarUrl = ev.sender ? Avatar.avatarUrlForMember( + ev.sender, 40, 40, 'crop' + ) : null; + + var notification = new global.Notification( + title, + { + "body": msg, + "icon": avatarUrl, + "tag": "matrixreactsdk" + } + ); + + notification.onclick = function() { + dis.dispatch({ + action: 'view_room', + room_id: room.roomId + }); + global.focus(); + }; + + /*var audioClip; + + if (audioNotification) { + audioClip = playAudio(audioNotification); + }*/ + + global.setTimeout(function() { + notification.close(); + }, 5 * 1000); + + }, + start: function() { this.boundOnRoomTimeline = this.onRoomTimeline.bind(this); + this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); - this.state = { 'toolbarHidden' : false }; + MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); + this.toolbarHidden = false; }, stop: function() { if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); + MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } }, @@ -96,7 +167,7 @@ module.exports = { }, setToolbarHidden: function(hidden) { - this.state.toolbarHidden = hidden; + this.toolbarHidden = hidden; dis.dispatch({ action: "notifier_enabled", value: this.isEnabled() @@ -104,11 +175,18 @@ module.exports = { }, isToolbarHidden: function() { - return this.state.toolbarHidden; + return this.toolbarHidden; + }, + + onSyncStateChange: function(state) { + if (state === "PREPARED" || state === "SYNCING") { + this.isPrepared = true; + } }, onRoomTimeline: function(ev, room, toStartOfTimeline) { if (toStartOfTimeline) return; + if (!this.isPrepared) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) return; if (!this.isEnabled()) { @@ -122,3 +200,8 @@ module.exports = { } }; +if (!global.mxNotifier) { + global.mxNotifier = Notifier; +} + +module.exports = global.mxNotifier; \ No newline at end of file diff --git a/src/Presence.js b/src/Presence.js index d77058abd8..e776cca078 100644 --- a/src/Presence.js +++ b/src/Presence.js @@ -15,58 +15,54 @@ limitations under the License. */ var MatrixClientPeg = require("./MatrixClientPeg"); +var dis = require("./dispatcher"); // Time in ms after that a user is considered as unavailable/away var UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins var PRESENCE_STATES = ["online", "offline", "unavailable"]; -// The current presence state -var state, timer; - -module.exports = { +class Presence { /** * Start listening the user activity to evaluate his presence state. * Any state change will be sent to the Home Server. */ - start: function() { - var self = this; + start() { this.running = true; - if (undefined === state) { - // The user is online if they move the mouse or press a key - document.onmousemove = function() { self._resetTimer(); }; - document.onkeypress = function() { self._resetTimer(); }; + if (undefined === this.state) { this._resetTimer(); + this.dispatcherRef = dis.register(this._onUserActivity.bind(this)); } - }, + } /** * Stop tracking user activity */ - stop: function() { + stop() { this.running = false; - if (timer) { - clearTimeout(timer); - timer = undefined; + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + dis.unregister(this.dispatcherRef); } - state = undefined; - }, + this.state = undefined; + } /** * Get the current presence state. * @returns {string} the presence state (see PRESENCE enum) */ - getState: function() { - return state; - }, + getState() { + return this.state; + } /** * Set the presence state. * If the state has changed, the Home Server will be notified. * @param {string} newState the new presence state (see PRESENCE enum) */ - setState: function(newState) { - if (newState === state) { + setState(newState) { + if (newState === this.state) { return; } if (PRESENCE_STATES.indexOf(newState) === -1) { @@ -75,33 +71,42 @@ module.exports = { if (!this.running) { return; } - state = newState; - MatrixClientPeg.get().setPresence(state).done(function() { + var old_state = this.state; + this.state = newState; + var self = this; + MatrixClientPeg.get().setPresence(this.state).done(function() { console.log("Presence: %s", newState); }, function(err) { console.error("Failed to set presence: %s", err); + self.state = old_state; }); - }, + } /** * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * @private */ - _onUnavailableTimerFire: function() { + _onUnavailableTimerFire() { this.setState("unavailable"); - }, + } + + _onUserActivity() { + this._resetTimer(); + } /** * Callback called when the user made an action on the page * @private */ - _resetTimer: function() { + _resetTimer() { var self = this; this.setState("online"); // Re-arm the timer - clearTimeout(timer); - timer = setTimeout(function() { + clearTimeout(this.timer); + this.timer = setTimeout(function() { self._onUnavailableTimerFire(); }, UNAVAILABLE_TIME_MS); } -}; +} + +module.exports = new Presence(); diff --git a/src/Resend.js b/src/Resend.js new file mode 100644 index 0000000000..0e67a306bd --- /dev/null +++ b/src/Resend.js @@ -0,0 +1,33 @@ +var MatrixClientPeg = require('./MatrixClientPeg'); +var dis = require('./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 + }); + }, + + removeFromQueue: function(event) { + MatrixClientPeg.get().getScheduler().removeEventFromQueue(event); + var room = MatrixClientPeg.get().getRoom(event.getRoomId()); + if (!room) { + return; + } + room.removeEvents([event.getId()]); + } +}; \ No newline at end of file diff --git a/src/Signup.js b/src/Signup.js new file mode 100644 index 0000000000..02ddaacc6d --- /dev/null +++ b/src/Signup.js @@ -0,0 +1,346 @@ +"use strict"; +var MatrixClientPeg = require("./MatrixClientPeg"); +var SignupStages = require("./SignupStages"); +var dis = require("./dispatcher"); +var q = require("q"); + +const EMAIL_STAGE_TYPE = "m.login.email.identity"; + +/** + * A base class for common functionality between Registration and Login e.g. + * storage of HS/IS URLs. + */ +class Signup { + constructor(hsUrl, isUrl) { + this._hsUrl = hsUrl; + this._isUrl = isUrl; + } + + getHomeserverUrl() { + return this._hsUrl; + } + + getIdentityServerUrl() { + return this._isUrl; + } + + setHomeserverUrl(hsUrl) { + this._hsUrl = hsUrl; + } + + setIdentityServerUrl(isUrl) { + this._isUrl = isUrl; + } +} + +/** + * Registration logic class + */ +class Register extends Signup { + constructor(hsUrl, isUrl) { + super(hsUrl, isUrl); + this.setStep("START"); + this.data = null; // from the server + // random other stuff (e.g. query params, NOT params from the server) + this.params = {}; + this.credentials = null; + this.activeStage = null; + this.registrationPromise = null; + // These values MUST be undefined else we'll send "username: null" which + // will error on Synapse rather than having the key absent. + this.username = undefined; // desired + this.email = undefined; // desired + this.password = undefined; // desired + } + + setClientSecret(secret) { + this.params.clientSecret = secret; + } + + setSessionId(sessionId) { + this.params.sessionId = sessionId; + } + + setRegistrationUrl(regUrl) { + this.params.registrationUrl = regUrl; + } + + setIdSid(idSid) { + this.params.idSid = idSid; + } + + getStep() { + return this._step; + } + + getCredentials() { + return this.credentials; + } + + getServerData() { + return this.data || {}; + } + + getPromise() { + return this.registrationPromise; + } + + setStep(step) { + this._step = 'Register.' + step; + // TODO: + // It's a shame this is going to the global dispatcher, we only really + // want things which have an instance of this class to be able to add + // listeners... + console.log("Dispatching 'registration_step_update' for step %s", this._step); + dis.dispatch({ + action: "registration_step_update" + }); + } + + register(formVals) { + var {username, password, email} = formVals; + this.email = email; + this.username = username; + this.password = password; + + // feels a bit wrong to be clobbering the global client for something we + // don't even know if it'll work, but we'll leave this here for now to + // not complicate matters further. It would be nicer to isolate this + // logic entirely from the rest of the app though. + MatrixClientPeg.replaceUsingUrls( + this._hsUrl, + this._isUrl + ); + return this._tryRegister(); + } + + _tryRegister(authDict) { + var self = this; + return MatrixClientPeg.get().register( + this.username, this.password, this.params.sessionId, authDict + ).then(function(result) { + self.credentials = result; + self.setStep("COMPLETE"); + return result; // contains the credentials + }, function(error) { + if (error.httpStatus === 401 && error.data && error.data.flows) { + self.data = error.data || {}; + var flow = self.chooseFlow(error.data.flows); + + if (flow) { + console.log("Active flow => %s", JSON.stringify(flow)); + var flowStage = self.firstUncompletedStage(flow); + return self.startStage(flowStage); + } + else { + throw new Error("Unable to register - missing email address?"); + } + } else { + if (error.errcode === 'M_USER_IN_USE') { + throw new Error("Username in use"); + } else if (error.httpStatus == 401) { + throw new Error("Authorisation failed!"); + } else if (error.httpStatus >= 400 && error.httpStatus < 500) { + throw new Error(`Registration failed! (${error.httpStatus})`); + } else if (error.httpStatus >= 500 && error.httpStatus < 600) { + throw new Error( + `Server error during registration! (${error.httpStatus})` + ); + } else if (error.name == "M_MISSING_PARAM") { + // The HS hasn't remembered the login params from + // the first try when the login email was sent. + throw new Error( + "This home server does not support resuming registration." + ); + } + } + }); + } + + firstUncompletedStage(flow) { + for (var i = 0; i < flow.stages.length; ++i) { + if (!this.hasCompletedStage(flow.stages[i])) { + return flow.stages[i]; + } + } + } + + hasCompletedStage(stageType) { + var completed = (this.data || {}).completed || []; + return completed.indexOf(stageType) !== -1; + } + + startStage(stageName) { + var self = this; + this.setStep(`STEP_${stageName}`); + var StageClass = SignupStages[stageName]; + if (!StageClass) { + // no idea how to handle this! + throw new Error("Unknown stage: " + stageName); + } + + var stage = new StageClass(MatrixClientPeg.get(), this); + this.activeStage = stage; + return stage.complete().then(function(request) { + if (request.auth) { + console.log("Stage %s is returning an auth dict", stageName); + return self._tryRegister(request.auth); + } + else { + // never resolve the promise chain. This is for things like email auth + // which display a "check your email" message and relies on the + // link in the email to actually register you. + console.log("Waiting for external action."); + return q.defer().promise; + } + }); + } + + chooseFlow(flows) { + // If the user gave us an email then we want to pick an email + // flow we can do, else any other flow. + var emailFlow = null; + var otherFlow = null; + flows.forEach(function(flow) { + var flowHasEmail = false; + for (var stageI = 0; stageI < flow.stages.length; ++stageI) { + var stage = flow.stages[stageI]; + + if (!SignupStages[stage]) { + // we can't do this flow, don't have a Stage impl. + return; + } + + if (stage === EMAIL_STAGE_TYPE) { + flowHasEmail = true; + } + } + + if (flowHasEmail) { + emailFlow = flow; + } else { + otherFlow = flow; + } + }); + + if (this.email || this.hasCompletedStage(EMAIL_STAGE_TYPE)) { + // we've been given an email or we've already done an email part + return emailFlow; + } else { + return otherFlow; + } + } + + recheckState() { + // feels a bit wrong to be clobbering the global client for something we + // don't even know if it'll work, but we'll leave this here for now to + // not complicate matters further. It would be nicer to isolate this + // logic entirely from the rest of the app though. + MatrixClientPeg.replaceUsingUrls( + this._hsUrl, + this._isUrl + ); + // We've been given a bunch of data from a previous register step, + // this only happens for email auth currently. It's kinda ming we need + // to know this though. A better solution would be to ask the stages if + // they are ready to do something rather than accepting that we know about + // email auth and its internals. + this.params.hasEmailInfo = ( + this.params.clientSecret && this.params.sessionId && this.params.idSid + ); + + if (this.params.hasEmailInfo) { + this.registrationPromise = this.startStage(EMAIL_STAGE_TYPE); + } + return this.registrationPromise; + } + + tellStage(stageName, data) { + if (this.activeStage && this.activeStage.type === stageName) { + console.log("Telling stage %s about something..", stageName); + this.activeStage.onReceiveData(data); + } + } +} + + +class Login extends Signup { + constructor(hsUrl, isUrl) { + super(hsUrl, isUrl); + this._currentFlowIndex = 0; + this._flows = []; + } + + getFlows() { + var self = this; + // feels a bit wrong to be clobbering the global client for something we + // don't even know if it'll work, but we'll leave this here for now to + // not complicate matters further. It would be nicer to isolate this + // logic entirely from the rest of the app though. + MatrixClientPeg.replaceUsingUrls( + this._hsUrl, + this._isUrl + ); + return MatrixClientPeg.get().loginFlows().then(function(result) { + self._flows = result.flows; + self._currentFlowIndex = 0; + // technically the UI should display options for all flows for the + // user to then choose one, so return all the flows here. + return self._flows; + }); + } + + chooseFlow(flowIndex) { + this._currentFlowIndex = flowIndex; + } + + getCurrentFlowStep() { + // technically the flow can have multiple steps, but no one does this + // for login so we can ignore it. + var flowStep = this._flows[this._currentFlowIndex]; + return flowStep ? flowStep.type : null; + } + + loginViaPassword(username, pass) { + var self = this; + var isEmail = username.indexOf("@") > 0; + var loginParams = { + password: pass + }; + if (isEmail) { + loginParams.medium = 'email'; + loginParams.address = username; + } else { + loginParams.user = username; + } + + return MatrixClientPeg.get().login('m.login.password', loginParams).then(function(data) { + return q({ + homeserverUrl: self._hsUrl, + identityServerUrl: self._isUrl, + userId: data.user_id, + accessToken: data.access_token + }); + }, function(error) { + if (error.httpStatus == 400 && loginParams.medium) { + error.friendlyText = ( + 'This Home Server does not support login using email address.' + ); + } + else if (error.httpStatus === 403) { + error.friendlyText = ( + 'Incorrect username and/or password.' + ); + } + else { + error.friendlyText = ( + 'There was a problem logging in. (HTTP ' + error.httpStatus + ")" + ); + } + throw error; + }); + } +} + +module.exports.Register = Register; +module.exports.Login = Login; diff --git a/src/SignupStages.js b/src/SignupStages.js new file mode 100644 index 0000000000..738732b9e2 --- /dev/null +++ b/src/SignupStages.js @@ -0,0 +1,196 @@ +"use strict"; +var q = require("q"); + +/** + * An interface class which login types should abide by. + */ +class Stage { + constructor(type, matrixClient, signupInstance) { + this.type = type; + this.client = matrixClient; + this.signupInstance = signupInstance; + } + + complete() { + // Return a promise which is: + // RESOLVED => With an Object which has an 'auth' key which is the auth dict + // to submit. + // REJECTED => With an Error if there was a problem with this stage. + // Has a "message" string and an "isFatal" flag. + return q.reject("NOT IMPLEMENTED"); + } + + onReceiveData() { + // NOP + } +} +Stage.TYPE = "NOT IMPLEMENTED"; + + +/** + * This stage requires no auth. + */ +class DummyStage extends Stage { + constructor(matrixClient, signupInstance) { + super(DummyStage.TYPE, matrixClient, signupInstance); + } + + complete() { + return q({ + auth: { + type: DummyStage.TYPE + } + }); + } +} +DummyStage.TYPE = "m.login.dummy"; + + +/** + * This stage uses Google's Recaptcha to do auth. + */ +class RecaptchaStage extends Stage { + constructor(matrixClient, signupInstance) { + super(RecaptchaStage.TYPE, matrixClient, signupInstance); + this.defer = q.defer(); // resolved with the captcha response + this.publicKey = null; // from the HS + this.divId = null; // from the UI component + } + + // called when the UI component has loaded the recaptcha
so we can + // render to it. + onReceiveData(data) { + if (!data || !data.divId) { + return; + } + this.divId = data.divId; + this._attemptRender(); + } + + complete() { + var publicKey; + var serverParams = this.signupInstance.getServerData().params; + if (serverParams && serverParams["m.login.recaptcha"]) { + publicKey = serverParams["m.login.recaptcha"].public_key; + } + if (!publicKey) { + return q.reject({ + message: "This server has not supplied enough information for Recaptcha " + + "authentication", + isFatal: true + }); + } + this.publicKey = publicKey; + this._attemptRender(); + return this.defer.promise; + } + + _attemptRender() { + if (!global.grecaptcha) { + console.error("grecaptcha not loaded!"); + return; + } + if (!this.publicKey) { + console.error("No public key for recaptcha!"); + return; + } + if (!this.divId) { + console.error("No div ID specified!"); + return; + } + console.log("Rendering to %s", this.divId); + var self = this; + global.grecaptcha.render(this.divId, { + sitekey: this.publicKey, + callback: function(response) { + console.log("Received captcha response"); + self.defer.resolve({ + auth: { + type: 'm.login.recaptcha', + response: response + } + }); + } + }); + } +} +RecaptchaStage.TYPE = "m.login.recaptcha"; + + +/** + * This state uses the IS to verify email addresses. + */ +class EmailIdentityStage extends Stage { + constructor(matrixClient, signupInstance) { + super(EmailIdentityStage.TYPE, matrixClient, signupInstance); + } + + _completeVerify() { + // pull out the host of the IS URL by creating an anchor element + var isLocation = document.createElement('a'); + isLocation.href = this.signupInstance.getIdentityServerUrl(); + + return q({ + auth: { + type: 'm.login.email.identity', + threepid_creds: { + sid: this.signupInstance.params.idSid, + client_secret: this.signupInstance.params.clientSecret, + id_server: isLocation.host + } + } + }); + } + + /** + * Complete the email stage. + * + * This is called twice under different circumstances: + * 1) When requesting an email token from the IS + * 2) When validating query parameters received from the link in the email + */ + complete() { + // TODO: The Registration class shouldn't really know this info. + if (this.signupInstance.params.hasEmailInfo) { + return this._completeVerify(); + } + + var clientSecret = this.client.generateClientSecret(); + var nextLink = this.signupInstance.params.registrationUrl + + '?client_secret=' + + encodeURIComponent(clientSecret) + + "&hs_url=" + + encodeURIComponent(this.signupInstance.getHomeserverUrl()) + + "&is_url=" + + encodeURIComponent(this.signupInstance.getIdentityServerUrl()) + + "&session_id=" + + encodeURIComponent(this.signupInstance.getServerData().session); + + return this.client.requestEmailToken( + this.signupInstance.email, + clientSecret, + 1, // TODO: Multiple send attempts? + nextLink + ).then(function(response) { + return {}; // don't want to make a request + }, function(error) { + console.error(error); + var e = { + isFatal: true + }; + if (error.errcode == 'THREEPID_IN_USE') { + e.message = "Email in use"; + } else { + e.message = 'Unable to contact the given identity server'; + } + throw e; + }); + } +} +EmailIdentityStage.TYPE = "m.login.email.identity"; + +module.exports = { + [DummyStage.TYPE]: DummyStage, + [RecaptchaStage.TYPE]: RecaptchaStage, + [EmailIdentityStage.TYPE]: EmailIdentityStage +}; \ No newline at end of file diff --git a/src/Skinner.js b/src/Skinner.js index ae48d85633..3e71d10e2d 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -32,6 +32,12 @@ class Skinner { if (comp) { return comp; } + // XXX: Temporarily also try 'views.' as we're currently + // leaving the 'views.' off views. + var comp = this.components['views.'+name]; + if (comp) { + return comp; + } throw new Error("No such component: "+name); } @@ -42,7 +48,24 @@ class Skinner { "If you want to change the active skin, call resetSkin first" ); } - this.components = skinObject; + this.components = {}; + var compKeys = Object.keys(skinObject.components); + for (var i = 0; i < compKeys.length; ++i) { + var comp = skinObject.components[compKeys[i]]; + this.addComponent(compKeys[i], comp); + } + } + + addComponent(name, comp) { + var slot = name; + if (comp.replaces !== undefined) { + if (comp.replaces.indexOf('.') > -1) { + slot = comp.replaces; + } else { + slot = name.substr(0, name.lastIndexOf('.') + 1) + comp.replaces.split('.').pop(); + } + } + this.components[slot] = comp; } reset() { diff --git a/src/controllers/organisms/LogoutPrompt.js b/src/UnreadStatus.js similarity index 60% rename from src/controllers/organisms/LogoutPrompt.js rename to src/UnreadStatus.js index 5e5011ea97..c8693c1e50 100644 --- a/src/controllers/organisms/LogoutPrompt.js +++ b/src/UnreadStatus.js @@ -14,20 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -var dis = require("../../dispatcher"); - module.exports = { - logOut: function() { - dis.dispatch({action: 'logout'}); - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - cancelPrompt: function() { - if (this.props.onFinished) { - this.props.onFinished(); + /** + * Returns true iff this event arriving in a room should affect the room's + * count of unread messages + */ + eventTriggersUnreadCount: function(ev) { + if (ev.getType() == "m.room.member") { + return false; + } else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { + return false; } + return true; } }; - diff --git a/src/UserActivity.js b/src/UserActivity.js new file mode 100644 index 0000000000..b283b9a58e --- /dev/null +++ b/src/UserActivity.js @@ -0,0 +1,68 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var dis = require("./dispatcher"); + +var MIN_DISPATCH_INTERVAL = 1 * 1000; + +/** + * This class watches for user activity (moving the mouse or pressing a key) + * and dispatches the user_activity action at times when the user is interacting + * with the app (but at a much lower frequency than mouse move events) + */ +class UserActivity { + + /** + * Start listening to user activity + */ + start() { + document.onmousemove = this._onUserActivity.bind(this); + document.onkeypress = this._onUserActivity.bind(this); + this.lastActivityAtTs = new Date().getTime(); + this.lastDispatchAtTs = 0; + } + + /** + * Stop tracking user activity + */ + stop() { + document.onmousemove = undefined; + document.onkeypress = undefined; + } + + _onUserActivity(event) { + if (event.screenX) { + if (event.screenX === this.lastScreenX && + event.screenY === this.lastScreenY) + { + // mouse hasn't actually moved + return; + } + this.lastScreenX = event.screenX; + this.lastScreenY = event.screenY; + } + + this.lastActivityAtTs = (new Date).getTime(); + if (this.lastDispatchAtTs < this.lastActivityAtTs - MIN_DISPATCH_INTERVAL) { + this.lastDispatchAtTs = this.lastActivityAtTs; + dis.dispatch({ + action: 'user_activity' + }); + } + } +} + +module.exports = new UserActivity(); 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/component-index.js b/src/component-index.js new file mode 100644 index 0000000000..2f89e6d94b --- /dev/null +++ b/src/component-index.js @@ -0,0 +1,75 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* + * THIS FILE IS AUTO-GENERATED + * You can edit it you like, but your changes will be overwritten, + * so you'd just be trying to swim upstream like a salmon. + * You are not a salmon. + */ + +module.exports.components = {}; +module.exports.components['structures.CreateRoom'] = require('./components/structures/CreateRoom'); +module.exports.components['structures.login.Login'] = require('./components/structures/login/Login'); +module.exports.components['structures.login.PostRegistration'] = require('./components/structures/login/PostRegistration'); +module.exports.components['structures.login.Registration'] = require('./components/structures/login/Registration'); +module.exports.components['structures.MatrixChat'] = require('./components/structures/MatrixChat'); +module.exports.components['structures.RoomView'] = require('./components/structures/RoomView'); +module.exports.components['structures.UploadBar'] = require('./components/structures/UploadBar'); +module.exports.components['structures.UserSettings'] = require('./components/structures/UserSettings'); +module.exports.components['views.avatars.MemberAvatar'] = require('./components/views/avatars/MemberAvatar'); +module.exports.components['views.avatars.RoomAvatar'] = require('./components/views/avatars/RoomAvatar'); +module.exports.components['views.create_room.CreateRoomButton'] = require('./components/views/create_room/CreateRoomButton'); +module.exports.components['views.create_room.Presets'] = require('./components/views/create_room/Presets'); +module.exports.components['views.create_room.RoomAlias'] = require('./components/views/create_room/RoomAlias'); +module.exports.components['views.dialogs.ErrorDialog'] = require('./components/views/dialogs/ErrorDialog'); +module.exports.components['views.dialogs.LogoutPrompt'] = require('./components/views/dialogs/LogoutPrompt'); +module.exports.components['views.dialogs.QuestionDialog'] = require('./components/views/dialogs/QuestionDialog'); +module.exports.components['views.elements.EditableText'] = require('./components/views/elements/EditableText'); +module.exports.components['views.elements.ProgressBar'] = require('./components/views/elements/ProgressBar'); +module.exports.components['views.elements.UserSelector'] = require('./components/views/elements/UserSelector'); +module.exports.components['views.login.CaptchaForm'] = require('./components/views/login/CaptchaForm'); +module.exports.components['views.login.CasLogin'] = require('./components/views/login/CasLogin'); +module.exports.components['views.login.CustomServerDialog'] = require('./components/views/login/CustomServerDialog'); +module.exports.components['views.login.LoginFooter'] = require('./components/views/login/LoginFooter'); +module.exports.components['views.login.LoginHeader'] = require('./components/views/login/LoginHeader'); +module.exports.components['views.login.PasswordLogin'] = require('./components/views/login/PasswordLogin'); +module.exports.components['views.login.RegistrationForm'] = require('./components/views/login/RegistrationForm'); +module.exports.components['views.login.ServerConfig'] = require('./components/views/login/ServerConfig'); +module.exports.components['views.messages.MessageEvent'] = require('./components/views/messages/MessageEvent'); +module.exports.components['views.messages.MFileBody'] = require('./components/views/messages/MFileBody'); +module.exports.components['views.messages.MImageBody'] = require('./components/views/messages/MImageBody'); +module.exports.components['views.messages.MVideoBody'] = require('./components/views/messages/MVideoBody'); +module.exports.components['views.messages.TextualBody'] = require('./components/views/messages/TextualBody'); +module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent'); +module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); +module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); +module.exports.components['views.rooms.MemberInfo'] = require('./components/views/rooms/MemberInfo'); +module.exports.components['views.rooms.MemberList'] = require('./components/views/rooms/MemberList'); +module.exports.components['views.rooms.MemberTile'] = require('./components/views/rooms/MemberTile'); +module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer'); +module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader'); +module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); +module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings'); +module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile'); +module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar'); +module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName'); +module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword'); +module.exports.components['views.settings.EnableNotificationsButton'] = require('./components/views/settings/EnableNotificationsButton'); +module.exports.components['views.voip.CallView'] = require('./components/views/voip/CallView'); +module.exports.components['views.voip.IncomingCallBox'] = require('./components/views/voip/IncomingCallBox'); +module.exports.components['views.voip.VideoFeed'] = require('./components/views/voip/VideoFeed'); +module.exports.components['views.voip.VideoView'] = require('./components/views/voip/VideoView'); diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js new file mode 100644 index 0000000000..d6002fb84c --- /dev/null +++ b/src/components/structures/CreateRoom.js @@ -0,0 +1,290 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var React = require("react"); +var MatrixClientPeg = require("../../MatrixClientPeg"); +var PresetValues = { + PrivateChat: "private_chat", + PublicChat: "public_chat", + Custom: "custom", +}; +var q = require('q'); +var encryption = require("../../encryption"); +var sdk = require('../../index'); + +module.exports = React.createClass({ + displayName: 'CreateRoom', + + propTypes: { + onRoomCreated: React.PropTypes.func, + }, + + phases: { + CONFIG: "CONFIG", // We're waiting for user to configure and hit create. + CREATING: "CREATING", // We're sending the request. + CREATED: "CREATED", // We successfully created the room. + ERROR: "ERROR", // There was an error while trying to create room. + }, + + getDefaultProps: function() { + return { + onRoomCreated: function() {}, + }; + }, + + getInitialState: function() { + return { + phase: this.phases.CONFIG, + error_string: "", + is_private: true, + share_history: false, + default_preset: PresetValues.PrivateChat, + topic: '', + room_name: '', + invited_users: [], + }; + }, + + onCreateRoom: function() { + var options = {}; + + if (this.state.room_name) { + options.name = this.state.room_name; + } + + if (this.state.topic) { + options.topic = this.state.topic; + } + + if (this.state.preset) { + if (this.state.preset != PresetValues.Custom) { + options.preset = this.state.preset; + } else { + options.initial_state = [ + { + type: "m.room.join_rules", + content: { + "join_rule": this.state.is_private ? "invite" : "public" + } + }, + { + type: "m.room.history_visibility", + content: { + "history_visibility": this.state.share_history ? "shared" : "invited" + } + }, + ]; + } + } + + options.invite = this.state.invited_users; + + var alias = this.getAliasLocalpart(); + if (alias) { + options.room_alias_name = alias; + } + + var cli = MatrixClientPeg.get(); + if (!cli) { + // TODO: Error. + console.error("Cannot create room: No matrix client."); + return; + } + + var deferred = cli.createRoom(options); + + var response; + + if (this.state.encrypt) { + deferred = deferred.then(function(res) { + response = res; + return encryption.enableEncryption( + cli, response.room_id, options.invite + ); + }).then(function() { + return q(response) } + ); + } + + this.setState({ + phase: this.phases.CREATING, + }); + + var self = this; + + deferred.then(function (resp) { + self.setState({ + phase: self.phases.CREATED, + }); + self.props.onRoomCreated(resp.room_id); + }, function(err) { + self.setState({ + phase: self.phases.ERROR, + error_string: err.toString(), + }); + }); + }, + + getPreset: function() { + return this.refs.presets.getPreset(); + }, + + getName: function() { + return this.refs.name_textbox.getName(); + }, + + getTopic: function() { + return this.refs.topic.getTopic(); + }, + + getAliasLocalpart: function() { + return this.refs.alias.getAliasLocalpart(); + }, + + getInvitedUsers: function() { + return this.refs.user_selector.getUserIds(); + }, + + onPresetChanged: function(preset) { + switch (preset) { + case PresetValues.PrivateChat: + this.setState({ + preset: preset, + is_private: true, + share_history: false, + }); + break; + case PresetValues.PublicChat: + this.setState({ + preset: preset, + is_private: false, + share_history: true, + }); + break; + case PresetValues.Custom: + this.setState({ + preset: preset, + }); + break; + } + }, + + onPrivateChanged: function(ev) { + this.setState({ + preset: PresetValues.Custom, + is_private: ev.target.checked, + }); + }, + + onShareHistoryChanged: function(ev) { + this.setState({ + preset: PresetValues.Custom, + share_history: ev.target.checked, + }); + }, + + onTopicChange: function(ev) { + this.setState({ + topic: ev.target.value, + }); + }, + + onNameChange: function(ev) { + this.setState({ + room_name: ev.target.value, + }); + }, + + onInviteChanged: function(invited_users) { + this.setState({ + invited_users: invited_users, + }); + }, + + onAliasChanged: function(alias) { + this.setState({ + alias: alias + }) + }, + + onEncryptChanged: function(ev) { + this.setState({ + encrypt: ev.target.checked, + }); + }, + + render: function() { + var curr_phase = this.state.phase; + if (curr_phase == this.phases.CREATING) { + var Loader = sdk.getComponent("elements.Spinner"); + return ( + + ); + } else { + var error_box = ""; + if (curr_phase == this.phases.ERROR) { + error_box = ( +
+ An error occured: {this.state.error_string} +
+ ); + } + + var CreateRoomButton = sdk.getComponent("create_room.CreateRoomButton"); + var RoomAlias = sdk.getComponent("create_room.RoomAlias"); + var Presets = sdk.getComponent("create_room.Presets"); + var UserSelector = sdk.getComponent("elements.UserSelector"); + var RoomHeader = sdk.getComponent("rooms.RoomHeader"); + + return ( +
+ +
+
+
+ cancel_button =
Cancel
+ save_button =
Save Changes
+ } else { + // + + var searchStatus; + if (this.props.searchInfo && this.props.searchInfo.searchTerm) { + searchStatus =
 ({ this.props.searchInfo.searchCount } results)
; + } + + name = +
+
{ this.props.room.name }
+ { searchStatus } +
+ +
+
+ if (topic) topic_el =
{ topic.getContent().topic }
; + } + + var roomAvatar = null; + if (this.props.room) { + roomAvatar = ( + + ); + } + + var leave_button; + if (this.props.onLeaveClick) { + leave_button = +
+ Leave room +
; + } + + header = +
+
+
+ { roomAvatar } +
+
+ { name } + { topic_el } +
+
+ {cancel_button} + {save_button} +
+ { leave_button } +
+ Search +
+
+
+ } + + return ( +
+ { header } +
+ ); + }, +}); diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js new file mode 100644 index 0000000000..6af1af31dd --- /dev/null +++ b/src/components/views/rooms/RoomList.js @@ -0,0 +1,307 @@ +/* +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 GeminiScrollbar = require('react-gemini-scrollbar'); +var MatrixClientPeg = require("../../../MatrixClientPeg"); +var RoomListSorter = require("../../../RoomListSorter"); +var UnreadStatus = require('../../../UnreadStatus'); +var dis = require("../../../dispatcher"); +var sdk = require('../../../index'); + +var HIDE_CONFERENCE_CHANS = true; + +module.exports = React.createClass({ + displayName: 'RoomList', + + propTypes: { + ConferenceHandler: React.PropTypes.any, + collapsed: React.PropTypes.bool, + currentRoom: React.PropTypes.string + }, + + 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); + + var s = this.getRoomLists(); + s.activityMap = {}; + this.setState(s); + }, + + componentDidMount: function() { + this.dispatcherRef = dis.register(this.onAction); + }, + + onAction: function(payload) { + switch (payload.action) { + case 'view_tooltip': + this.tooltip = payload.tooltip; + this._repositionTooltip(); + if (this.tooltip) this.tooltip.style.display = 'block'; + break + } + }, + + componentWillUnmount: function() { + dis.unregister(this.dispatcherRef); + if (MatrixClientPeg.get()) { + MatrixClientPeg.get().removeListener("Room", this.onRoom); + MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); + MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); + MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); + } + }, + + componentWillReceiveProps: function(newProps) { + this.state.activityMap[newProps.selectedRoom] = undefined; + this.setState({ + activityMap: this.state.activityMap + }); + }, + + onRoom: function(room) { + this.refreshRoomList(); + }, + + onRoomTimeline: function(ev, room, toStartOfTimeline) { + if (toStartOfTimeline) return; + + var hl = 0; + if ( + room.roomId != this.props.selectedRoom && + ev.getSender() != MatrixClientPeg.get().credentials.userId) + { + if (UnreadStatus.eventTriggersUnreadCount(ev)) { + hl = 1; + } + + var me = room.getMember(MatrixClientPeg.get().credentials.userId); + var actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + if ((actions && actions.tweaks && actions.tweaks.highlight) || + (me && me.membership == "invite")) + { + hl = 2; + } + } + + var newState = this.getRoomLists(); + if (hl > 0) { + // 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; + + } + // still want to update the list even if the highlight status + // hasn't changed because the ordering may have + this.setState(newState); + }, + + onRoomName: function(room) { + this.refreshRoomList(); + }, + + onRoomTags: function(event, room) { + this.refreshRoomList(); + }, + + onRoomStateEvents: function(ev, state) { + setTimeout(this.refreshRoomList, 0); + }, + + onRoomMemberName: function(ev, member) { + 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 self = this; + var s = { lists: {} }; + + s.lists["im.vector.fake.invite"] = []; + s.lists["m.favourite"] = []; + s.lists["im.vector.fake.recent"] = []; + s.lists["m.lowpriority"] = []; + s.lists["im.vector.fake.archived"] = []; + + MatrixClientPeg.get().getRooms().forEach(function(room) { + var me = room.getMember(MatrixClientPeg.get().credentials.userId); + + if (me && me.membership == "invite") { + s.lists["im.vector.fake.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 + var joinedMembers = room.getJoinedMembers(); + if (joinedMembers.length === 2) { + var otherMember = joinedMembers.filter(function(m) { + return m.userId !== me.userId + })[0]; + var ConfHandler = self.props.ConferenceHandler; + if (ConfHandler && ConfHandler.isConferenceUser(otherMember.userId)) { + // console.log("Hiding conference 1:1 room %s", room.roomId); + shouldShowRoom = false; + } + } + } + + 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["im.vector.fake.recent"].push(room); + } + } + } + }); + + //console.log("calculated new roomLists; im.vector.fake.recent = " + s.lists["im.vector.fake.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 = ReactDOM.findDOMNode(this); + this.tooltip.style.top = (scroll.parentElement.offsetTop + this.tooltip.parentElement.offsetTop - scroll.children[2].scrollTop) + "px"; + } + }, + + onShowClick: function() { + dis.dispatch({ + action: 'show_left_panel', + }); + }, + + render: function() { + var expandButton = this.props.collapsed ? + > : + null; + + var RoomSubList = sdk.getComponent('structures.RoomSubList'); + var self = this; + + return ( + +
+ { expandButton } + + + + + + + + { Object.keys(self.state.lists).map(function(tagName) { + if (!tagName.match(/^(m\.(favourite|lowpriority)|im\.vector\.fake\.(invite|recent|archived))$/)) { + return + + } + }) } + + + + +
+
+ ); + } +}); 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 ( +
+