diff --git a/CHANGELOG.md b/CHANGELOG.md index 262d55c6da..70f946d7cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +Changes in [0.6.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.3) (2016-06-03) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.2...v0.6.3) + + * Change invite text field wording + * Fix bug with new email invite UX where the invite could get wedged + * Label app versions sensibly in UserSettings + +Changes in [0.6.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.2) (2016-06-02) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.1...v0.6.2) + + * Correctly bump dep on matrix-js-sdk 0.5.4 + +Changes in [0.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.1) (2016-06-02) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.6.0...v0.6.1) + + * Fix focusing race in new UX for 3pid invites + * Fix jenkins.sh + +Changes in [0.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.6.0) (2016-06-02) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.5.2...v0.6.0) + + * implement new UX for 3pid invites + [\#297](https://github.com/matrix-org/matrix-react-sdk/pull/297) + * multiple URL preview support + [\#290](https://github.com/matrix-org/matrix-react-sdk/pull/290) + * Add a fallback home server to log into + [\#293](https://github.com/matrix-org/matrix-react-sdk/pull/293) + * Hopefully fix memory leak with velocity + [\#291](https://github.com/matrix-org/matrix-react-sdk/pull/291) + * Support for enabling email notifications + [\#289](https://github.com/matrix-org/matrix-react-sdk/pull/289) + * Correct Readme instructions how to customize the UI + [\#286](https://github.com/matrix-org/matrix-react-sdk/pull/286) + * Avoid rerendering during Room unmount + [\#285](https://github.com/matrix-org/matrix-react-sdk/pull/285) + Changes in [0.5.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.5.2) (2016-04-22) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.5.1...v0.5.2) diff --git a/jenkins.sh b/jenkins.sh index 51fab5d020..eeb7d7d56e 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -8,9 +8,6 @@ nvm use 4 set -x -# install the version of js-sdk provided to us by jenkins -npm install ./node_modules/matrix-js-sdk-*.tgz - # install the other dependencies npm install diff --git a/package.json b/package.json index d46e9b2621..5c9a67c734 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.5.2", + "version": "0.6.3", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -31,15 +31,14 @@ "highlight.js": "^8.9.1", "linkifyjs": "^2.0.0-beta.4", "marked": "^0.3.5", - "matrix-js-sdk": "^0.5.2", + "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "q": "^1.4.1", "react": "^15.0.1", "react-dom": "^15.0.1", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#c3d942e", "sanitize-html": "^1.11.1", - "velocity-animate": "^1.2.3", - "velocity-ui-pack": "^1.2.2" + "velocity-vector": "vector-im/velocity#059e3b2" }, "//babelversion": [ "brief experiments with babel6 seems to show that it generates source ", diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index cf7131eb7b..9bb1388e76 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -100,7 +100,7 @@ module.exports = { return this.getEmailPusher(pushers, address) !== undefined; }, - addEmailPusher: function(address) { + addEmailPusher: function(address, data) { return MatrixClientPeg.get().setPusher({ kind: 'email', app_id: "m.email", @@ -108,7 +108,7 @@ module.exports = { app_display_name: 'Email Notifications', device_display_name: address, lang: navigator.language, - data: {}, + data: data, append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address }); }, diff --git a/src/Velociraptor.js b/src/Velociraptor.js index 0abf34b230..f45925867f 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,6 +1,6 @@ var React = require('react'); var ReactDom = require('react-dom'); -var Velocity = require('velocity-animate'); +var Velocity = require('velocity-vector'); /** * The Velociraptor contains components and animates transitions with velocity. @@ -117,7 +117,8 @@ module.exports = React.createClass({ // and the FAQ entry, "Preventing memory leaks when // creating/destroying large numbers of elements" // (https://github.com/julianshapiro/velocity/issues/47) - Velocity.Utilities.removeData(this.nodes[k]); + var domNode = ReactDom.findDOMNode(this.nodes[k]); + Velocity.Utilities.removeData(domNode); } this.nodes[k] = node; }, diff --git a/src/VelocityBounce.js b/src/VelocityBounce.js index c85aa254fa..168b0b14af 100644 --- a/src/VelocityBounce.js +++ b/src/VelocityBounce.js @@ -1,4 +1,4 @@ -var Velocity = require('velocity-animate'); +var Velocity = require('velocity-vector'); // courtesy of https://github.com/julianshapiro/velocity/issues/283 // We only use easeOutBounce (easeInBounce is just sort of nonsensical) diff --git a/src/component-index.js b/src/component-index.js index 967cc5d685..3570523bde 100644 --- a/src/component-index.js +++ b/src/component-index.js @@ -79,6 +79,7 @@ module.exports.components['views.rooms.EntityTile'] = require('./components/view module.exports.components['views.rooms.EventTile'] = require('./components/views/rooms/EventTile'); module.exports.components['views.rooms.InviteMemberList'] = require('./components/views/rooms/InviteMemberList'); module.exports.components['views.rooms.LinkPreviewWidget'] = require('./components/views/rooms/LinkPreviewWidget'); +module.exports.components['views.rooms.MemberDeviceInfo'] = require('./components/views/rooms/MemberDeviceInfo'); 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'); diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 2f7a6ed8ec..b70c89e2d8 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -37,11 +37,13 @@ var MatrixTools = require('../../MatrixTools'); var linkifyMatrix = require("../../linkify-matrix"); var KeyCode = require('../../KeyCode'); +var createRoom = require("../../createRoom"); + module.exports = React.createClass({ displayName: 'MatrixChat', propTypes: { - config: React.PropTypes.object.isRequired, + config: React.PropTypes.object, ConferenceHandler: React.PropTypes.any, onNewScreen: React.PropTypes.func, registrationUrl: React.PropTypes.string, @@ -84,7 +86,8 @@ module.exports = React.createClass({ getDefaultProps: function() { return { - startingQueryParams: {} + startingQueryParams: {}, + config: {}, }; }, @@ -97,10 +100,9 @@ module.exports = React.createClass({ else if (window.localStorage && window.localStorage.getItem("mx_hs_url")) { return window.localStorage.getItem("mx_hs_url"); } - else if (this.props.config) { - return this.props.config.default_hs_url + else { + return this.props.config.default_hs_url || "https://matrix.org"; } - return "https://matrix.org"; }, getFallbackHsUrl: function() { @@ -116,10 +118,9 @@ module.exports = React.createClass({ else if (window.localStorage && window.localStorage.getItem("mx_is_url")) { return window.localStorage.getItem("mx_is_url"); } - else if (this.props.config) { - return this.props.config.default_is_url + else { + return this.props.config.default_is_url || "https://vector.im" } - return "https://matrix.org"; }, componentWillMount: function() { @@ -391,6 +392,10 @@ module.exports = React.createClass({ }); break; case 'view_room': + // Takes both room ID and room alias: if switching to a room the client is already + // know to be in (eg. user clicks on a room in the recents panel), supply only the + // ID. If the user is clicking on a room in the context of the alias being presented + // to them, supply the room alias and optionally the room ID. this._viewRoom( payload.room_id, payload.room_alias, payload.show_settings, payload.event_id, payload.third_party_invite, payload.oob_data @@ -422,42 +427,6 @@ module.exports = React.createClass({ this._viewRoom(allRooms[roomIndex].roomId); } break; - case 'view_room_alias': - if (!this.state.logged_in) { - this.starting_room_alias_payload = payload; - // Login is the default screen, so we'd do this anyway, - // but this will set the URL bar appropriately. - dis.dispatch({ action: 'start_login' }); - return; - } - - var foundRoom = MatrixTools.getRoomForAlias( - MatrixClientPeg.get().getRooms(), payload.room_alias - ); - if (foundRoom) { - dis.dispatch({ - action: 'view_room', - room_id: foundRoom.roomId, - room_alias: payload.room_alias, - event_id: payload.event_id, - third_party_invite: payload.third_party_invite, - oob_data: payload.oob_data, - }); - return; - } - // resolve the alias and *then* view it - MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias).done( - function(result) { - dis.dispatch({ - action: 'view_room', - room_id: result.room_id, - room_alias: payload.room_alias, - event_id: payload.event_id, - third_party_invite: payload.third_party_invite, - oob_data: payload.oob_data, - }); - }); - break; case 'view_user_settings': this._setPage(this.PageTypes.UserSettings); this.notifyNewScreen('settings'); @@ -466,48 +435,7 @@ module.exports = React.createClass({ //this._setPage(this.PageTypes.CreateRoom); //this.notifyNewScreen('new'); - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - var Loader = sdk.getComponent("elements.Spinner"); - var modal = Modal.createDialog(Loader); - - if (MatrixClientPeg.get().isGuest()) { - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "Guest users can't create new rooms. Please register to create room and start a chat." - }); - return; - } - - // XXX: FIXME: deduplicate this with MemberInfo's 'start chat' impl - MatrixClientPeg.get().createRoom({ - preset: "private_chat", - // Allow guests by default since the room is private and they'd - // need an invite. This means clicking on a 3pid invite email can - // actually drop you right in to a chat. - initial_state: [ - { - content: { - guest_access: 'can_join' - }, - type: 'm.room.guest_access', - state_key: '', - } - ], - }).done(function(res) { - modal.close(); - dis.dispatch({ - action: 'view_room', - room_id: res.room_id, - // show_settings: true, - }); - }, function(err) { - modal.close(); - Modal.createDialog(ErrorDialog, { - title: "Failed to create room", - description: err.toString() - }); - }); + createRoom().done(); break; case 'view_room_directory': this._setPage(this.PageTypes.RoomDirectory); @@ -572,8 +500,6 @@ module.exports = React.createClass({ this.focusComposer = true; var newState = { - currentRoom: roomId, - currentRoomAlias: roomAlias, initialEventId: eventId, highlightedEventId: eventId, initialEventPixelOffset: undefined, @@ -582,6 +508,18 @@ module.exports = React.createClass({ roomOobData: oob_data, }; + // If an alias has been provided, we use that and only that, + // since otherwise we'll prefer to pass in an ID to RoomView + // but if we're not in the room, we should join by alias rather + // than ID. + if (roomAlias) { + newState.currentRoomAlias = roomAlias; + newState.currentRoom = null; + } else { + newState.currentRoomAlias = null; + newState.currentRoom = roomId; + } + // if we aren't given an explicit event id, look for one in the // scrollStateMap. if (!eventId) { @@ -858,22 +796,28 @@ module.exports = React.createClass({ inviterName: params.inviter_name, }; + var payload = { + action: 'view_room', + event_id: eventId, + third_party_invite: third_party_invite, + oob_data: oob_data, + }; if (roomString[0] == '#') { - dis.dispatch({ - action: 'view_room_alias', - room_alias: roomString, - event_id: eventId, - third_party_invite: third_party_invite, - oob_data: oob_data, - }); + payload.room_alias = roomString; } else { - dis.dispatch({ - action: 'view_room', - room_id: roomString, - event_id: eventId, - third_party_invite: third_party_invite, - oob_data: oob_data, - }); + payload.room_id = roomString; + } + + // we can't view a room unless we're logged in + // (a guest account is fine) + if (!this.state.logged_in) { + this.starting_room_alias_payload = payload; + // Login is the default screen, so we'd do this anyway, + // but this will set the URL bar appropriately. + dis.dispatch({ action: 'start_login' }); + return; + } else { + dis.dispatch(payload); } } else { @@ -889,7 +833,7 @@ module.exports = React.createClass({ onAliasClick: function(event, alias) { event.preventDefault(); - dis.dispatch({action: 'view_room_alias', room_alias: alias}); + dis.dispatch({action: 'view_room', room_alias: alias}); }, onUserClick: function(event, userId) { @@ -1084,14 +1028,14 @@ module.exports = React.createClass({ oobData={this.state.roomOobData} highlightedEventId={this.state.highlightedEventId} eventPixelOffset={this.state.initialEventPixelOffset} - key={this.state.currentRoom} + key={this.state.currentRoom || this.state.currentRoomAlias} opacity={this.state.middleOpacity} ConferenceHandler={this.props.ConferenceHandler} /> ); right_panel = break; case this.PageTypes.UserSettings: - page_element = + page_element = right_panel = break; case this.PageTypes.CreateRoom: @@ -1159,6 +1103,7 @@ module.exports = React.createClass({ guestAccessToken={this.state.guestAccessToken} defaultHsUrl={this.props.config.default_hs_url} defaultIsUrl={this.props.config.default_is_url} + brand={this.props.config.brand} customHsUrl={this.getCurrentHsUrl()} customIsUrl={this.getCurrentIsUrl()} registrationUrl={this.props.registrationUrl} diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 16b4892bc0..c8e878118b 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -86,6 +86,10 @@ module.exports = React.createClass({ // to manage its animations this._readReceiptMap = {}; + // Remember the read marker ghost node so we can do the cleanup that + // Velocity requires + this._readMarkerGhostNode = null; + this._isMounted = true; }, @@ -422,9 +426,16 @@ module.exports = React.createClass({ }, _startAnimation: function(ghostNode) { - Velocity(ghostNode, {opacity: '0', width: '10%'}, - {duration: 400, easing: 'easeInSine', - delay: 1000}); + if (this._readMarkerGhostNode) { + Velocity.Utilities.removeData(this._readMarkerGhostNode); + } + this._readMarkerGhostNode = ghostNode; + + if (ghostNode) { + Velocity(ghostNode, {opacity: '0', width: '10%'}, + {duration: 400, easing: 'easeInSine', + delay: 1000}); + } }, _getReadMarkerGhostTile: function() { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 33bbb510e3..9fc335236c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -39,6 +39,7 @@ var dis = require("../../dispatcher"); var Tinter = require("../../Tinter"); var rate_limited_func = require('../../ratelimitedfunc'); var ObjectUtils = require('../../ObjectUtils'); +var MatrixTools = require('../../MatrixTools'); var DEBUG = false; @@ -55,13 +56,6 @@ module.exports = React.createClass({ ConferenceHandler: React.PropTypes.any, // the ID for this room (or, if we don't know it, an alias for it) - // - // XXX: if this is an alias, we will display a 'join' dialogue, - // regardless of whether we are already a member, or if the room is - // peekable. Currently there is a big mess, where at least four - // different components (RoomView, MatrixChat, RoomDirectory, - // SlashCommands) have logic for turning aliases into rooms, and each - // of them do it differently and have different edge cases. roomAddress: React.PropTypes.string.isRequired, // An object representing a third party invite to join this room @@ -100,7 +94,14 @@ module.exports = React.createClass({ }, getInitialState: function() { - var room = MatrixClientPeg.get().getRoom(this.props.roomAddress); + var room; + if (this.props.roomAddress[0] == '!') { + room = MatrixClientPeg.get().getRoom(this.props.roomAddress); + } else { + room = MatrixTools.getRoomForAlias( + MatrixClientPeg.get().getRooms(), this.props.roomAddress + ); + } return { room: room, roomLoading: !room, @@ -677,6 +678,16 @@ module.exports = React.createClass({ uploadFile: function(file) { var self = this; + + if (MatrixClientPeg.get().isGuest()) { + var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); + Modal.createDialog(NeedToRegisterDialog, { + title: "Please Register", + description: "Guest users can't upload files. Please register to upload." + }); + return; + } + ContentMessages.sendContentToRoom( file, this.state.room.roomId, MatrixClientPeg.get() ).done(undefined, function(error) { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 0be6271ea4..635f9c5413 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -31,7 +31,9 @@ module.exports = React.createClass({ propTypes: { version: React.PropTypes.string, - onClose: React.PropTypes.func + onClose: React.PropTypes.func, + // The brand string given when creating email pushers + brand: React.PropTypes.string, }, getDefaultProps: function() { @@ -244,6 +246,23 @@ module.exports = React.createClass({ }); }, + _renderDeviceInfo: function() { + var client = MatrixClientPeg.get(); + var deviceId = client.deviceId; + var olmKey = client.getDeviceEd25519Key() || ""; + return ( +
+

Cryptography

+
+
    +
  • Device ID: {deviceId}
  • +
  • Device key: {olmKey}
  • +
+
+
+ ); + }, + render: function() { var self = this; var Loader = sdk.getComponent("elements.Spinner"); @@ -299,7 +318,7 @@ module.exports = React.createClass({ onValueChanged={ this.onAddThreepidClicked } />
- Add + Add
); @@ -333,7 +352,7 @@ module.exports = React.createClass({

Notifications

- +
); } @@ -390,6 +409,8 @@ module.exports = React.createClass({ {notification_area} + {this._renderDeviceInfo()} +

Advanced

@@ -403,9 +424,8 @@ module.exports = React.createClass({ Identity Server is { MatrixClientPeg.get().getIdentityServerUrl() }
- Version {this.state.clientVersion} -
- {this.props.version} + matrix-react-sdk version: {this.state.clientVersion}
+ vector-web version: {this.props.version}
diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index d852991b9c..2f15a3b5df 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -22,6 +22,7 @@ var sdk = require('../../../index'); var dis = require('../../../dispatcher'); var Signup = require("../../../Signup"); var ServerConfig = require("../../views/login/ServerConfig"); +var MatrixClientPeg = require("../../../MatrixClientPeg"); var RegistrationForm = require("../../views/login/RegistrationForm"); var CaptchaForm = require("../../views/login/CaptchaForm"); @@ -40,6 +41,7 @@ module.exports = React.createClass({ customIsUrl: React.PropTypes.string, defaultHsUrl: React.PropTypes.string, defaultIsUrl: React.PropTypes.string, + brand: React.PropTypes.string, email: React.PropTypes.string, username: React.PropTypes.string, guestAccessToken: React.PropTypes.string, @@ -145,6 +147,26 @@ module.exports = React.createClass({ identityServerUrl: self.registerLogic.getIdentityServerUrl(), accessToken: response.access_token }); + + if (self.props.brand) { + MatrixClientPeg.get().getPushers().done((resp)=>{ + var pushers = resp.pushers; + for (var i = 0; i < pushers.length; ++i) { + if (pushers[i].kind == 'email') { + var emailPusher = pushers[i]; + emailPusher.data = { brand: self.props.brand }; + MatrixClientPeg.get().setPusher(emailPusher).done(() => { + console.log("Set email branding to " + self.props.brand); + }, (error) => { + console.error("Couldn't set email branding: " + error); + }); + } + } + }, (error) => { + console.error("Couldn't get pushers: " + error); + }); + } + }, function(err) { if (err.message) { self.setState({ diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index d81ae98718..fed7ff079a 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -39,11 +39,11 @@ module.exports = React.createClass({ focus: true }; }, - + componentDidMount: function() { if (this.props.focus) { - // Set the cursor at the end of the text input - this.refs.textinput.value = this.props.value; + // Set the cursor at the end of the text input + this.refs.textinput.value = this.props.value; } }, @@ -83,13 +83,12 @@ module.exports = React.createClass({
- - +
); diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index 83bd1ab17c..a172d77bb4 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -17,8 +17,8 @@ limitations under the License. 'use strict'; var React = require('react'); -var Velocity = require('velocity-animate'); -require('velocity-ui-pack'); +var Velocity = require('velocity-vector'); +require('velocity-vector/velocity.ui'); var sdk = require('../../../index'); var Email = require('../../../email'); var Modal = require("../../../Modal"); diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index a72608d329..310da598fa 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -45,9 +45,9 @@ module.exports = React.createClass({ getInitialState: function() { return { - // the URL (if any) to be previewed with a LinkPreviewWidget + // the URLs (if any) to be previewed with a LinkPreviewWidget // inside this TextualBody. - link: null, + links: [], // track whether the preview widget is hidden widgetHidden: false, @@ -57,9 +57,11 @@ module.exports = React.createClass({ componentDidMount: function() { linkifyElement(this.refs.content, linkifyMatrix.options); - var link = this.findLink(this.refs.content.children); - if (link) { - this.setState({ link: link.getAttribute("href") }); + var links = this.findLinks(this.refs.content.children); + if (links.length) { + this.setState({ links: links.map((link)=>{ + return link.getAttribute("href"); + })}); // lazy-load the hidden state of the preview widget from localstorage if (global.localStorage) { @@ -74,27 +76,32 @@ module.exports = React.createClass({ shouldComponentUpdate: function(nextProps, nextState) { // exploit that events are immutable :) + // ...and that .links is only ever set in componentDidMount and never changes return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || nextProps.highlights !== this.props.highlights || nextProps.highlightLink !== this.props.highlightLink || - nextState.link !== this.state.link || + nextState.links !== this.state.links || nextState.widgetHidden !== this.state.widgetHidden); }, - findLink: function(nodes) { + findLinks: function(nodes) { + var links = []; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node.tagName === "A" && node.getAttribute("href")) { - return this.isLinkPreviewable(node) ? node : undefined; + if (this.isLinkPreviewable(node)) { + links.push(node); + } } else if (node.tagName === "PRE" || node.tagName === "CODE") { - return; + continue; } else if (node.children && node.children.length) { - return this.findLink(node.children) + links = links.concat(this.findLinks(node.children)); } } + return links; }, isLinkPreviewable: function(node) { @@ -160,14 +167,17 @@ module.exports = React.createClass({ {highlightLink: this.props.highlightLink}); - var widget; - if (this.state.link && !this.state.widgetHidden) { + var widgets; + if (this.state.links.length && !this.state.widgetHidden) { var LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget'); - widget = ; + widgets = this.state.links.map((link)=>{ + return ; + }); } switch (content.msgtype) { @@ -176,21 +186,21 @@ module.exports = React.createClass({ return ( * { name } { body } - { widget } + { widgets } ); case "m.notice": return ( { body } - { widget } + { widgets } ); default: // including "m.text" return ( { body } - { widget } + { widgets } ); } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 7db8af9312..ff02139d87 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -128,16 +128,24 @@ module.exports = React.createClass({ }, getInitialState: function() { - return {menu: false, allReadAvatars: false}; + return {menu: false, allReadAvatars: false, verified: null}; }, componentWillMount: function() { // don't do RR animations until we are mounted this._suppressReadReceiptAnimation = true; + this._verifyEvent(this.props.mxEvent); }, componentDidMount: function() { this._suppressReadReceiptAnimation = false; + MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified); + }, + + componentWillReceiveProps: function (nextProps) { + if (nextProps.mxEvent !== this.props.mxEvent) { + this._verifyEvent(nextProps.mxEvent); + } }, shouldComponentUpdate: function (nextProps, nextState) { @@ -152,6 +160,31 @@ module.exports = React.createClass({ return false; }, + componentWillUnmount: function() { + var client = MatrixClientPeg.get(); + if (client) { + client.removeListener("deviceVerified", this.onDeviceVerified); + } + }, + + onDeviceVerified: function(userId, device) { + if (userId == this.props.mxEvent.getSender()) { + this._verifyEvent(this.props.mxEvent); + } + }, + + _verifyEvent: function(mxEvent) { + var verified = null; + + if (mxEvent.isEncrypted()) { + verified = MatrixClientPeg.get().isEventSenderVerified(mxEvent); + } + + this.setState({ + verified: verified + }); + }, + _propsEqual: function(objA, objB) { var keysA = Object.keys(objA); var keysB = Object.keys(objB); @@ -346,6 +379,8 @@ module.exports = React.createClass({ mx_EventTile_last: this.props.last, mx_EventTile_contextual: this.props.contextual, menu: this.state.menu, + mx_EventTile_verified: this.state.verified == true, + mx_EventTile_unverified: this.state.verified == false, }); var timestamp = diff --git a/src/components/views/rooms/InviteMemberList.js b/src/components/views/rooms/InviteMemberList.js index 480066771b..5246e2e54d 100644 --- a/src/components/views/rooms/InviteMemberList.js +++ b/src/components/views/rooms/InviteMemberList.js @@ -26,6 +26,7 @@ module.exports = React.createClass({ propTypes: { roomId: React.PropTypes.string.isRequired, onInvite: React.PropTypes.func.isRequired, // fn(inputText) + onThirdPartyInvite: React.PropTypes.func.isRequired, // fn(inputText) onSearchQueryChanged: React.PropTypes.func // fn(inputText) }, @@ -49,10 +50,19 @@ module.exports = React.createClass({ } }, + componentDidMount: function() { + // initialise the email tile + this.onSearchQueryChanged(''); + }, + onInvite: function(ev) { this.props.onInvite(this._input); }, + onThirdPartyInvite: function(ev) { + this.props.onThirdPartyInvite(this._input); + }, + onSearchQueryChanged: function(input) { this._input = input; var EntityTile = sdk.getComponent("rooms.EntityTile"); @@ -68,9 +78,10 @@ module.exports = React.createClass({ this._emailEntity = new Entities.newEntity( } - className="mx_EntityTile_invitePlaceholder" - presenceState="online" onClick={this.onInvite} name={label} />, + avatarJsx={ } + className="mx_EntityTile_invitePlaceholder" + presenceState="online" onClick={this.onThirdPartyInvite} name={"Invite by email"} + />, function(query) { return true; // always show this } @@ -89,7 +100,7 @@ module.exports = React.createClass({ } return ( - ✔ + ); + } else { + button = ( +
+ Verify +
+ ); + } + return ( +
+
{this.props.device.id}
+
{this.props.device.key}
+ {indicator} + {button} +
+ ); + }, +}); diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 76e5af7612..1eee280eb5 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -30,27 +30,106 @@ var MatrixClientPeg = require("../../../MatrixClientPeg"); var dis = require("../../../dispatcher"); var Modal = require("../../../Modal"); var sdk = require('../../../index'); +var createRoom = require('../../../createRoom'); module.exports = React.createClass({ displayName: 'MemberInfo', + propTypes: { + member: React.PropTypes.object.isRequired, + onFinished: React.PropTypes.func, + }, + getDefaultProps: function() { return { onFinished: function() {} }; }, - componentDidMount: function() { - // work out the current state - if (this.props.member) { - var memberState = this._calculateOpsPermissions(this.props.member); - this.setState(memberState); + getInitialState: function() { + return { + can: { + kick: false, + ban: false, + mute: false, + modifyLevel: false + }, + muted: false, + isTargetMod: false, + updating: 0, + devicesLoading: true, + devices: null, } }, + + componentWillMount: function() { + this._cancelDeviceList = null; + }, + + componentDidMount: function() { + this._updateStateForNewMember(this.props.member); + MatrixClientPeg.get().on("deviceVerified", this.onDeviceVerified); + }, + componentWillReceiveProps: function(newProps) { - var memberState = this._calculateOpsPermissions(newProps.member); - this.setState(memberState); + if (this.props.member.userId != newProps.member.userId) { + this._updateStateForNewMember(newProps.member); + } + }, + + componentWillUnmount: function() { + var client = MatrixClientPeg.get(); + if (client) { + client.removeListener("deviceVerified", this.onDeviceVerified); + } + if (this._cancelDeviceList) { + this._cancelDeviceList(); + } + }, + + onDeviceVerified: function(userId, device) { + if (userId == this.props.member.userId) { + // no need to re-download the whole thing; just update our copy of + // the list. + var devices = MatrixClientPeg.get().listDeviceKeys(userId); + this.setState({devices: devices}); + } + }, + + _updateStateForNewMember: function(member) { + var newState = this._calculateOpsPermissions(member); + newState.devicesLoading = true; + newState.devices = null; + this.setState(newState); + + if (this._cancelDeviceList) { + this._cancelDeviceList(); + this._cancelDeviceList = null; + } + + this._downloadDeviceList(member); + }, + + _downloadDeviceList: function(member) { + var cancelled = false; + this._cancelDeviceList = function() { cancelled = true; } + + var client = MatrixClientPeg.get(); + var self = this; + client.downloadKeys([member.userId], true).finally(function() { + self._cancelDeviceList = null; + }).done(function() { + if (cancelled) { + // we got cancelled - presumably a different user now + return; + } + var devices = client.listDeviceKeys(member.userId); + self.setState({devicesLoading: false, devices: devices}); + }, function(err) { + console.log("Error downloading devices", err); + self.setState({devicesLoading: false}); + }); }, onKick: function() { @@ -315,50 +394,15 @@ module.exports = React.createClass({ this.props.onFinished(); } else { - if (MatrixClientPeg.get().isGuest()) { - var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); - Modal.createDialog(NeedToRegisterDialog, { - title: "Please Register", - description: "Guest users can't create new rooms. Please register to create room and start a chat." - }); - self.props.onFinished(); - return; - } - self.setState({ updating: self.state.updating + 1 }); - MatrixClientPeg.get().createRoom({ - // XXX: FIXME: deduplicate this with "view_create_room" in MatrixChat - invite: [this.props.member.userId], - preset: "private_chat", - // Allow guests by default since the room is private and they'd - // need an invite. This means clicking on a 3pid invite email can - // actually drop you right in to a chat. - initial_state: [ - { - content: { - guest_access: 'can_join' - }, - type: 'm.room.guest_access', - state_key: '', - } - ], - }).then( - function(res) { - dis.dispatch({ - action: 'view_room', - room_id: res.room_id - }); - self.props.onFinished(); - }, function(err) { - Modal.createDialog(ErrorDialog, { - title: "Failure to start chat", - description: err.message - }); - self.props.onFinished(); - } - ).finally(()=>{ + createRoom({ + createOpts: { + invite: [this.props.member.userId], + }, + }).finally(function() { + self.props.onFinished(); self.setState({ updating: self.state.updating - 1 }); - }); + }).done(); } }, @@ -367,21 +411,7 @@ module.exports = React.createClass({ action: 'leave_room', room_id: this.props.member.roomId, }); - this.props.onFinished(); - }, - - getInitialState: function() { - return { - can: { - kick: false, - ban: false, - mute: false, - modifyLevel: false - }, - muted: false, - isTargetMod: false, - updating: 0, - } + this.props.onFinished(); }, _calculateOpsPermissions: function(member) { @@ -475,6 +505,36 @@ module.exports = React.createClass({ Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); }, + _renderDevices: function() { + var devices = this.state.devices; + var MemberDeviceInfo = sdk.getComponent('rooms.MemberDeviceInfo'); + var Spinner = sdk.getComponent("elements.Spinner"); + + var devComponents; + if (this.state.devicesLoading) { + // still loading + devComponents = ; + } else if (devices === null) { + devComponents = "Unable to load device list"; + } else if (devices.length === 0) { + devComponents = "No registered devices"; + } else { + devComponents = []; + for (var i = 0; i < devices.length; i++) { + devComponents.push(); + } + } + + return ( +
+

Devices

+ {devComponents} +
+ ); + }, + render: function() { var startChat, kickButton, banButton, muteButton, giveModButton, spinner; if (this.props.member.userId !== MatrixClientPeg.get().credentials.userId) { @@ -551,6 +611,8 @@ module.exports = React.createClass({ { startChat } + { this._renderDevices() } + { adminTools } { spinner } @@ -558,4 +620,3 @@ module.exports = React.createClass({ ); } }); - diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index f029c519bc..328f9774c7 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -166,6 +166,25 @@ module.exports = React.createClass({ }); }, 500), + onThirdPartyInvite: function(inputText) { + var TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); + Modal.createDialog(TextInputDialog, { + title: "Invite members by email", + description: "Please enter one or more email addresses", + value: inputText, + button: "Invite", + onFinished: (should_invite, addresses)=>{ + if (should_invite) { + // defer the actual invite to the next event loop to give this + // Modal a chance to unmount in case onInvite() triggers a new one + setTimeout(()=>{ + this.onInvite(addresses); + }, 0); + } + } + }); + }, + onInvite: function(inputText) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); @@ -387,7 +406,9 @@ module.exports = React.createClass({ // console.log(memberA + " and " + memberB + " have same power level"); if (memberA.name && memberB.name) { // console.log("comparing names: " + memberA.name + " and " + memberB.name); - return memberA.name.localeCompare(memberB.name); + var nameA = memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name; + var nameB = memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name; + return nameA.localeCompare(nameB); } else { return 0; @@ -512,6 +533,7 @@ module.exports = React.createClass({ inviteMemberListSection = ( ); } diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 2d17accd45..15b13a6bcc 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -53,6 +53,15 @@ module.exports = React.createClass({ }, onUploadClick: function(ev) { + if (MatrixClientPeg.get().isGuest()) { + var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); + Modal.createDialog(NeedToRegisterDialog, { + title: "Please Register", + description: "Guest users can't upload files. Please register to upload." + }); + return; + } + this.refs.uploadInput.click(); }, diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index fd8bcbfe96..8764700c5a 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -34,7 +34,7 @@ module.exports = React.createClass({ getInitialState: function() { var tags = {}; Object.keys(this.props.room.tags).forEach(function(tagName) { - tags[tagName] = {}; + tags[tagName] = ['yep']; }); var areNotifsMuted = false; @@ -180,7 +180,7 @@ module.exports = React.createClass({ // tags if (this.state.tags_changed) { var tagDiffs = ObjectUtils.getKeyValueArrayDiffs(originalState.tags, this.state.tags); - // [ {place: add, key: "m.favourite", val: "yep"} ] + // [ {place: add, key: "m.favourite", val: ["yep"]} ] tagDiffs.forEach(function(diff) { switch (diff.place) { case "add": diff --git a/src/components/views/rooms/SearchableEntityList.js b/src/components/views/rooms/SearchableEntityList.js index c09fc2faee..a22126025c 100644 --- a/src/components/views/rooms/SearchableEntityList.js +++ b/src/components/views/rooms/SearchableEntityList.js @@ -48,6 +48,7 @@ var SearchableEntityList = React.createClass({ getInitialState: function() { return { query: "", + focused: false, truncateAt: this.props.truncateAt, results: this.getSearchResults("", this.props.entities) }; @@ -101,7 +102,7 @@ var SearchableEntityList = React.createClass({ getSearchResults: function(query, entities) { if (!query || query.length === 0) { - return this.props.emptyQueryShowsAll ? entities : [] + return this.props.emptyQueryShowsAll ? entities : [ entities[0] ] } return entities.filter(function(e) { return e.matches(query); @@ -134,13 +135,27 @@ var SearchableEntityList = React.createClass({
{ + if (this._blurTimeout) { + clearTimeout(this.blurTimeout); + } + this.setState({ focused: true }); + } } + onBlur={ ()=>{ + // nasty setTimeout heuristic to avoid the 'invite by email' prompt disappearing + // due to the onBlur before we can click on it + this._blurTimeout = setTimeout( + ()=>{ this.setState({ focused: false }) }, + 300 + ); + } } placeholder={this.props.searchPlaceholderText} />
); } var list; - if (this.state.results.length) { + if (this.state.results.length > 1 || this.state.focused) { if (this.props.truncateAt) { // caller wants list truncated var TruncatedList = sdk.getComponent("elements.TruncatedList"); list = ( @@ -172,10 +187,10 @@ var SearchableEntityList = React.createClass({ } return ( -
+
{ inputBox } { list } - { this.state.query.length ?

: '' } + { list ?

: '' }
); } diff --git a/src/createRoom.js b/src/createRoom.js new file mode 100644 index 0000000000..658561e78a --- /dev/null +++ b/src/createRoom.js @@ -0,0 +1,86 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +var MatrixClientPeg = require('./MatrixClientPeg'); +var Modal = require('./Modal'); +var sdk = require('./index'); +var dis = require("./dispatcher"); + +var q = require('q'); + +/** + * Create a new room, and switch to it. + * + * Returns a promise which resolves to the room id, or null if the + * action was aborted or failed. + * + * @param {object=} opts parameters for creating the room + * @param {object=} opts.createOpts set of options to pass to createRoom call. + */ +function createRoom(opts) { + var opts = opts || {}; + + var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + var NeedToRegisterDialog = sdk.getComponent("dialogs.NeedToRegisterDialog"); + var Loader = sdk.getComponent("elements.Spinner"); + + var client = MatrixClientPeg.get(); + if (client.isGuest()) { + Modal.createDialog(NeedToRegisterDialog, { + title: "Please Register", + description: "Guest users can't create new rooms. Please register to create room and start a chat." + }); + return q(null); + } + + // set some defaults for the creation + var createOpts = opts.createOpts || {}; + createOpts.preset = createOpts.preset || 'private_chat'; + createOpts.visibility = createOpts.visibility || 'private'; + + // Allow guests by default since the room is private and they'd + // need an invite. This means clicking on a 3pid invite email can + // actually drop you right in to a chat. + createOpts.initial_state = createOpts.initial_state || [ + { + content: { + guest_access: 'can_join' + }, + type: 'm.room.guest_access', + state_key: '', + } + ]; + + var modal = Modal.createDialog(Loader); + + return client.createRoom(createOpts).finally(function() { + modal.close(); + }).then(function(res) { + dis.dispatch({ + action: 'view_room', + room_id: res.room_id + }); + return res.room_id; + }, function(err) { + Modal.createDialog(ErrorDialog, { + title: "Failure to create room", + description: err.toString() + }); + return null; + }); +} + +module.exports = createRoom;